cli

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

commit 51634492de182ebdd01068d0aff8e8ea79b5bf56
parent 0824578d8b52742101c2491830523f8c756fb839
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 17:44:11 +0000

order: preflight seller inventory

- fetch current listing state before seller accept publishes
- run reducer-owned inventory accounting with the proposed acceptance
- reject stale listings and over-reserved accounting before signing
- keep decline behavior on the existing no-reservation path

Diffstat:
Msrc/runtime/order.rs | 471++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 455 insertions(+), 16 deletions(-)

diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -5,11 +5,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{KIND_LISTING, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST}; +use radroots_events::listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingStatus, +}; use radroots_events::trade::{ RadrootsActiveTradeMessageType, RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; use radroots_events_codec::d_tag::is_d_tag_base64url; +use radroots_events_codec::listing::decode::listing_from_event; use radroots_events_codec::trade::{ RadrootsTradeListingAddress, active_trade_envelope_from_event, active_trade_event_context_from_tags, active_trade_order_decision_event_build, @@ -28,8 +32,9 @@ use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, RadrootsActiveOrderStatus, + RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryBinAvailability, canonicalize_active_order_decision_for_signer, canonicalize_active_order_request_for_signer, - reduce_active_order_events, + reduce_active_order_events, reduce_listing_inventory_accounting, }; use serde::{Deserialize, Serialize}; @@ -131,6 +136,19 @@ struct ResolvedOrderSubmitRequest { } #[derive(Debug, Clone)] +struct ResolvedAccountingRequest { + listing_event_id: Option<String>, + record: RadrootsActiveOrderRequestRecord, +} + +#[derive(Debug, Clone)] +struct ResolvedInventoryListing { + event_id: String, + listing: RadrootsListing, + bins: Vec<RadrootsListingInventoryBinAvailability>, +} + +#[derive(Debug, Clone)] struct SellerOrderRequestResolution { target_relays: Vec<String>, connected_relays: Vec<String>, @@ -755,6 +773,30 @@ pub fn decide( } if resolution.requests.len() == 1 { let request = resolution.requests[0].clone(); + let status_view = status( + config, + &OrderStatusArgs { + key: args.key.clone(), + }, + )?; + if let Some(view) = order_decision_preflight_view_from_status( + config, + args, + &request, + &resolution, + &status_view, + ) { + return Ok(view); + } + if let Some(view) = order_accept_inventory_preflight_view( + config, + args, + &request, + &resolution, + &status_view, + )? { + return Ok(view); + } let signing = match resolve_local_order_decision_signing_identity( config, request.seller_pubkey.as_str(), @@ -776,21 +818,6 @@ pub fn decide( .as_str(); canonical_order_decision_payload(args, &request, signer_pubkey)? }; - let status_view = status( - config, - &OrderStatusArgs { - key: args.key.clone(), - }, - )?; - if let Some(view) = order_decision_preflight_view_from_status( - config, - args, - &request, - &resolution, - &status_view, - ) { - return Ok(view); - } if config.output.dry_run { return Ok(order_decision_dry_run_view( config, @@ -1529,6 +1556,387 @@ fn order_decision_preflight_view_from_status( Some(view) } +fn order_accept_inventory_preflight_view( + config: &RuntimeConfig, + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + resolution: &SellerOrderRequestResolution, + status: &OrderStatusView, +) -> Result<Option<OrderDecisionView>, RuntimeError> { + if args.decision != OrderDecisionArg::Accept { + return Ok(None); + } + + let listing = match fetch_current_inventory_listing(config, args, request, resolution, status)? + { + Ok(listing) => listing, + Err(view) => return Ok(Some(view)), + }; + if listing.event_id != request.listing_event_id.clone().unwrap_or_default() { + return Ok(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(), + )], + ))); + } + if !listing_is_active(&listing.listing) { + return Ok(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", + )], + ))); + } + + 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.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 projection = reduce_listing_inventory_accounting( + request.listing_addr.as_str(), + listing.event_id.as_str(), + listing.bins, + requests, + decisions, + ); + if projection.issues.is_empty() { + return Ok(None); + } + + let issues = projection + .issues + .into_iter() + .map(listing_inventory_accounting_issue_view) + .collect::<Vec<_>>(); + Ok(Some(order_decision_inventory_invalid_view( + config, + args, + request, + resolution, + status, + "order accept refused because visible inventory accounting is invalid", + issues, + ))) +} + +fn fetch_current_inventory_listing( + config: &RuntimeConfig, + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + resolution: &SellerOrderRequestResolution, + 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())), + }; + + 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}")) + })?; + 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)?; + 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 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 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, + 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_TRADE_ORDER_REQUEST + || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) + { + continue; + } + let record = listing_accounting_request_from_event(&event)?; + if record.listing_event_id.as_deref() == Some(listing.event_id.as_str()) { + records.push(record); + } + } + Ok(records) +} + +fn fetch_listing_accounting_decisions( + config: &RuntimeConfig, + request: &ResolvedSellerOrderRequest, +) -> Result<Vec<RadrootsActiveOrderDecisionRecord>, 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_TRADE_ORDER_DECISION + || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) + { + continue; + } + match order_status_record_from_event(&event)? { + OrderStatusRecord::Decision(record) => records.push(record), + OrderStatusRecord::Request(_) => {} + } + } + Ok(records) +} + +fn listing_accounting_request_from_event( + event: &RadrootsNostrEvent, +) -> Result<ResolvedAccountingRequest, RuntimeError> { + 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}")))?; + Ok(ResolvedAccountingRequest { + listing_event_id: context.listing_event.as_ref().map(|event| event.id.clone()), + record: RadrootsActiveOrderRequestRecord { + event_id: event.id, + author_pubkey: event.author, + payload: envelope.payload, + }, + }) +} + +fn active_request_record_from_resolved( + request: &ResolvedSellerOrderRequest, +) -> RadrootsActiveOrderRequestRecord { + RadrootsActiveOrderRequestRecord { + event_id: request.request_event_id.clone(), + author_pubkey: request.buyer_pubkey.clone(), + payload: RadrootsTradeOrderRequested { + 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(), + }, + } +} + +fn proposed_accept_decision_record( + request: &ResolvedSellerOrderRequest, +) -> Result<RadrootsActiveOrderDecisionRecord, RuntimeError> { + let payload = accepted_order_decision_payload_from_request(request); + let payload = + canonicalize_active_order_decision_for_signer(payload, request.seller_pubkey.as_str()) + .map_err(|error| { + RuntimeError::Config(format!("canonicalize order decision: {error}")) + })?; + Ok(RadrootsActiveOrderDecisionRecord { + event_id: format!("pending_accept:{}", request.order_id), + author_pubkey: request.seller_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, + 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 listing_inventory_accounting_issue_view( + issue_value: RadrootsListingInventoryAccountingIssue, +) -> OrderIssueView { + match issue_value { + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { + order_id, + event_ids, + } => issue_with_events( + "invalid_inventory_order", + "order_id", + format!("inventory accounting reported invalid active order `{order_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_dry_run_view( config: &RuntimeConfig, args: &OrderDecisionArgs, @@ -1886,6 +2294,37 @@ fn order_request_filter( Ok(filter) } +fn listing_event_filter( + listing_addr: &RadrootsTradeListingAddress, +) -> 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 order_listing_request_filter( + seller_pubkey: &str, + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_TRADE_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 order_listing_decision_filter(listing_addr: &str) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_TRADE_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_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeError> { let filter = RadrootsNostrFilter::new() .kinds([