commit ccade76249dc46f2464cf8a39a2cd1a8e721c7b9
parent d922da2e8d578d271b32ad8a85ddc5acb962c2c8
Author: triesap <tyson@radroots.org>
Date: Tue, 28 Apr 2026 12:27:29 +0000
order: list seller order requests
Diffstat:
4 files changed, 305 insertions(+), 64 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1281,6 +1281,20 @@ pub struct OrderWorkflowView {
pub struct OrderHistoryView {
pub state: String,
pub source: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ #[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,
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
@@ -1293,6 +1307,8 @@ pub struct OrderHistoryView {
impl OrderHistoryView {
pub fn disposition(&self) -> CommandDisposition {
match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "unavailable" => CommandDisposition::ExternalUnavailable,
"error" => CommandDisposition::InternalError,
_ => CommandDisposition::Success,
}
@@ -1304,12 +1320,26 @@ pub struct OrderHistoryEntryView {
pub id: String,
pub state: 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 listing_lookup: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub buyer_account_id: 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 item_count: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub created_at_unix: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub submitted_at_unix: Option<u64>,
pub updated_at_unix: u64,
#[serde(skip_serializing_if = "Option::is_none")]
diff --git a/src/main.rs b/src/main.rs
@@ -379,6 +379,7 @@ fn external_network_operation(operation_id: &str) -> bool {
| "listing.publish"
| "listing.archive"
| "order.submit"
+ | "order.event.list"
| "order.event.watch"
)
}
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -91,10 +91,14 @@ impl OperationService<OrderEventListRequest> for OrderOperationService<'_> {
fn execute(
&self,
- _request: OperationRequest<OrderEventListRequest>,
+ request: OperationRequest<OrderEventListRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
- let view = map_runtime(crate::runtime::order::history(self.config))?;
- serialized_target_result::<OrderEventListResult, _>(&view)
+ let order_id = string_input(&request, "order_id");
+ let view =
+ crate::runtime::order::history(self.config, order_id.as_deref()).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ history_result::<OrderEventListResult>(request.operation_id(), &view)
}
}
@@ -142,6 +146,25 @@ where
}
}
+fn history_result<R>(
+ operation_id: &str,
+ view: &crate::domain::runtime::OrderHistoryView,
+) -> Result<OperationResult<R>, OperationAdapterError>
+where
+ R: OperationResultData,
+{
+ match view.disposition() {
+ CommandDisposition::Success => serialized_target_result::<R, _>(view),
+ disposition => Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ view.reason.clone().unwrap_or_else(|| {
+ format!("order event list finished with state `{}`", view.state)
+ }),
+ )),
+ }
+}
+
fn required_order_key<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError>
where
P: OperationRequestPayload + OperationRequestData,
@@ -290,7 +313,7 @@ mod tests {
}
#[test]
- fn order_event_list_wraps_history_without_legacy_action() {
+ fn order_event_list_requires_relay_configuration() {
let dir = tempdir().expect("tempdir");
let config = sample_config(dir.path());
let service = OperationAdapter::new(OrderOperationService::new(&config));
@@ -301,13 +324,12 @@ mod tests {
.expect("order event list request");
let envelope = service
.execute(request)
- .expect("order event list result")
- .to_envelope(OperationContext::default().envelope_context("req_order_events"))
- .expect("order event list envelope");
+ .expect_err("order event list unconfigured")
+ .to_output_error();
- assert_eq!(envelope.operation_id, "order.event.list");
- assert_eq!(envelope.result["state"], "empty");
- assert_eq!(envelope.result["actions"][0], "radroots order list");
+ assert_eq!(envelope.code, "operation_unavailable");
+ assert_eq!(envelope.exit_code, 3);
+ assert!(envelope.message.contains("configured relay"));
}
#[test]
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -4,11 +4,18 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use radroots_events::RadrootsNostrEventPtr;
-use radroots_events::kinds::KIND_LISTING;
-use radroots_events::trade::{RadrootsTradeOrderItem, RadrootsTradeOrderRequested};
+use radroots_events::kinds::{KIND_LISTING, KIND_TRADE_ORDER_REQUEST};
+use radroots_events::trade::{
+ RadrootsActiveTradeMessageType, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
+};
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::trade::{
- RadrootsTradeListingAddress, active_trade_order_request_event_build,
+ RadrootsTradeListingAddress, active_trade_event_context_from_tags,
+ active_trade_order_request_event_build, active_trade_order_request_from_event,
+};
+use radroots_nostr::prelude::{
+ RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag,
+ radroots_nostr_kind,
};
use radroots_replica_db::{ReplicaSql, nostr_event_state, trade_product};
use radroots_replica_db_schema::nostr_event_state::{
@@ -20,15 +27,16 @@ use radroots_trade::order::canonicalize_active_order_request_for_signer;
use serde::{Deserialize, Serialize};
use crate::domain::runtime::{
- OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderIssueView,
- OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, OrderWatchView,
+ OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView, OrderHistoryView,
+ OrderIssueView, OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, OrderWatchView,
RelayFailureView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
use crate::runtime::config::{RuntimeConfig, SignerBackend};
use crate::runtime::direct_relay::{
- DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity,
+ DirectRelayFailure, DirectRelayFetchReceipt, DirectRelayPublishReceipt,
+ fetch_events_from_relays, publish_parts_with_identity,
};
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime_args::{
@@ -38,8 +46,9 @@ use crate::runtime_args::{
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_EVENT_STATE_UNAVAILABLE_REASON: &str =
- "relay-backed order event state is not implemented";
+const ORDER_EVENT_LIST_SOURCE: &str = "direct Nostr relay fetch · selected seller identity";
+const ORDER_EVENT_WATCH_UNAVAILABLE_REASON: &str =
+ "relay-backed order event watch is not implemented";
const ORDERS_DIR: &str = "orders/drafts";
static ORDER_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -568,7 +577,7 @@ pub fn watch(
order_id: loaded.document.order.order_id.clone(),
job_id: None,
interval_ms: args.interval_ms,
- reason: Some(ORDER_EVENT_STATE_UNAVAILABLE_REASON.to_owned()),
+ reason: Some(ORDER_EVENT_WATCH_UNAVAILABLE_REASON.to_owned()),
workflow: None,
frames: Vec::new(),
actions: vec![format!(
@@ -578,54 +587,34 @@ pub fn watch(
})
}
-pub fn history(config: &RuntimeConfig) -> Result<OrderHistoryView, RuntimeError> {
- let dir = drafts_dir(config);
- if !dir.exists() {
- return Ok(OrderHistoryView {
- state: "empty".to_owned(),
- source: ORDER_SOURCE.to_owned(),
- count: 0,
- reason: Some("no relay-backed order events recorded yet".to_owned()),
- orders: Vec::new(),
- actions: vec!["radroots order list".to_owned()],
- });
+pub fn history(
+ config: &RuntimeConfig,
+ order_id: Option<&str>,
+) -> Result<OrderHistoryView, RuntimeError> {
+ if config.relay.urls.is_empty() {
+ return Ok(order_history_unconfigured(
+ None,
+ "order event list requires at least one configured relay".to_owned(),
+ Vec::new(),
+ ));
}
- let mut invalid_count = 0usize;
- for entry in fs::read_dir(&dir)? {
- let entry = entry?;
- let path = entry.path();
- if path.extension().and_then(|value| value.to_str()) != Some("toml") {
- continue;
- }
- if load_draft(path.as_path()).is_err() {
- invalid_count += 1;
+ let seller = match accounts::resolve_account(config)? {
+ Some(account) => account,
+ None => {
+ return Ok(order_history_unconfigured(
+ None,
+ "order event list requires a selected seller account".to_owned(),
+ config.relay.urls.clone(),
+ ));
}
- }
-
- let state = if invalid_count > 0 {
- "degraded"
- } else {
- "empty"
- };
-
- let reason = if invalid_count > 0 {
- Some(format!(
- "{invalid_count} invalid order draft file{} skipped while reading local order event state",
- if invalid_count == 1 { "" } else { "s" }
- ))
- } else {
- Some("no relay-backed order events recorded yet".to_owned())
};
+ let seller_pubkey = seller.record.public_identity.public_key_hex;
+ let filter = order_request_filter(seller_pubkey.as_str())?;
+ let receipt = fetch_events_from_relays(&config.relay.urls, filter)
+ .map_err(|error| RuntimeError::Network(error.to_string()))?;
- Ok(OrderHistoryView {
- state: state.to_owned(),
- source: ORDER_SOURCE.to_owned(),
- count: 0,
- reason,
- orders: Vec::new(),
- actions: vec!["radroots order list".to_owned()],
- })
+ Ok(order_history_from_receipt(seller_pubkey, order_id, receipt))
}
pub fn cancel(
@@ -665,7 +654,7 @@ pub fn cancel(
source: ORDER_SOURCE.to_owned(),
lookup: args.key.clone(),
order_id: Some(loaded.document.order.order_id.clone()),
- reason: Some(ORDER_EVENT_STATE_UNAVAILABLE_REASON.to_owned()),
+ reason: Some("seller order decisions are not implemented".to_owned()),
job: None,
actions: vec![format!(
"radroots order get {}",
@@ -674,6 +663,152 @@ pub fn cancel(
})
}
+fn order_history_unconfigured(
+ seller_pubkey: Option<String>,
+ reason: String,
+ target_relays: Vec<String>,
+) -> OrderHistoryView {
+ OrderHistoryView {
+ state: "unconfigured".to_owned(),
+ source: ORDER_EVENT_LIST_SOURCE.to_owned(),
+ seller_pubkey,
+ target_relays,
+ connected_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ fetched_count: 0,
+ decoded_count: 0,
+ skipped_count: 0,
+ count: 0,
+ reason: Some(reason),
+ orders: Vec::new(),
+ actions: vec!["radroots account create".to_owned()],
+ }
+}
+
+fn order_history_from_receipt(
+ seller_pubkey: String,
+ order_id: Option<&str>,
+ receipt: DirectRelayFetchReceipt,
+) -> OrderHistoryView {
+ let DirectRelayFetchReceipt {
+ target_relays,
+ connected_relays,
+ failed_relays,
+ events,
+ } = receipt;
+ let fetched_count = events.len();
+ let mut skipped_count = 0usize;
+ let mut orders = Vec::new();
+
+ for event in events {
+ match order_history_entry_from_event(&event, seller_pubkey.as_str()) {
+ Ok(entry) if order_id.is_none_or(|order_id| entry.id == order_id) => {
+ orders.push(entry);
+ }
+ Ok(_) | Err(_) => skipped_count += 1,
+ }
+ }
+
+ orders.sort_by(|left, right| {
+ right
+ .updated_at_unix
+ .cmp(&left.updated_at_unix)
+ .then_with(|| left.id.cmp(&right.id))
+ });
+
+ let decoded_count = orders.len();
+ let reason = if orders.is_empty() {
+ Some(match order_id {
+ Some(order_id) => {
+ format!("no relay-backed order request events matched `{order_id}`")
+ }
+ None => "no relay-backed order request events matched the selected seller".to_owned(),
+ })
+ } else {
+ None
+ };
+
+ OrderHistoryView {
+ state: if orders.is_empty() { "empty" } else { "ready" }.to_owned(),
+ source: ORDER_EVENT_LIST_SOURCE.to_owned(),
+ seller_pubkey: Some(seller_pubkey),
+ target_relays,
+ connected_relays,
+ failed_relays: relay_failures(failed_relays),
+ fetched_count,
+ decoded_count,
+ skipped_count,
+ count: orders.len(),
+ reason,
+ orders,
+ actions: Vec::new(),
+ }
+}
+
+fn order_history_entry_from_event(
+ event: &RadrootsNostrEvent,
+ seller_pubkey: &str,
+) -> Result<OrderHistoryEntryView, RuntimeError> {
+ let event_kind = event_kind_u32(event);
+ if event_kind != KIND_TRADE_ORDER_REQUEST {
+ return Err(RuntimeError::Config(format!(
+ "order event list received unexpected kind `{event_kind}`"
+ )));
+ }
+
+ let event = radroots_event_from_nostr(event);
+ let envelope = active_trade_order_request_from_event(&event)
+ .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?;
+ let context = active_trade_event_context_from_tags(
+ RadrootsActiveTradeMessageType::TradeOrderRequested,
+ &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
+ {
+ return Err(RuntimeError::Config(
+ "order request is not targeted at the selected seller".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);
+
+ Ok(OrderHistoryEntryView {
+ 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),
+ seller_pubkey: Some(envelope.payload.seller_pubkey),
+ 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 order_request_filter(seller_pubkey: &str) -> Result<RadrootsNostrFilter, RuntimeError> {
+ let filter = RadrootsNostrFilter::new()
+ .kind(radroots_nostr_kind(KIND_TRADE_ORDER_REQUEST as u16))
+ .limit(1_000);
+ radroots_nostr_filter_tag(filter, "p", vec![seller_pubkey.to_owned()])
+ .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}")))
+}
+
+fn event_kind_u32(event: &RadrootsNostrEvent) -> u32 {
+ u32::from(event.kind.as_u16())
+}
+
fn validate_scaffold_args(args: &OrderDraftCreateArgs) -> Result<(), RuntimeError> {
match (normalize_optional(args.bin_id.as_deref()), args.bin_count) {
(None, Some(_)) => Err(RuntimeError::Config(
@@ -1436,9 +1571,15 @@ impl From<OrderGetView> for OrderNewView {
#[cfg(test)]
mod tests {
+ use radroots_events::RadrootsNostrEventPtr;
+ use radroots_events::trade::{RadrootsTradeOrderItem, RadrootsTradeOrderRequested};
+ use radroots_events_codec::trade::active_trade_order_request_event_build;
+ use radroots_identity::RadrootsIdentity;
+ use radroots_nostr::prelude::radroots_nostr_build_event;
+
use super::{
ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem, collect_issues,
- inspect_document, next_order_id,
+ inspect_document, next_order_id, order_history_entry_from_event,
};
#[test]
@@ -1503,4 +1644,51 @@ mod tests {
.any(|issue| issue.field == "order.listing_event_id")
);
}
+
+ #[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 = RadrootsTradeOrderRequested {
+ order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
+ listing_addr: listing_addr.clone(),
+ buyer_pubkey: buyer_pubkey.clone(),
+ seller_pubkey: seller_pubkey.clone(),
+ items: vec![RadrootsTradeOrderItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ };
+ let parts = active_trade_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_history_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));
+ }
}