cli

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

commit 17ee8ae15fb94e2e347e1df4482a639e4b3d1f5b
parent 51634492de182ebdd01068d0aff8e8ea79b5bf56
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 17:48:35 +0000

order: report status inventory

- add order status inventory output for listing event and commitments
- expose accepted reserved bins and declined no-reserve state
- surface inventory commitment reducer issues in status output
- cover requested accepted declined and mismatched commitment status cases

Diffstat:
Msrc/domain/runtime.rs | 30++++++++++++++++++++++++++++++
Msrc/runtime/order.rs | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 226 insertions(+), 10 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1281,6 +1281,8 @@ pub struct OrderStatusView { #[serde(skip_serializing_if = "Option::is_none")] pub decision_event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub listing_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>, @@ -1288,6 +1290,8 @@ pub struct OrderStatusView { pub seller_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub last_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub inventory: Option<OrderInventoryView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reducer_issues: Vec<OrderIssueView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1308,6 +1312,32 @@ pub struct OrderStatusView { pub actions: Vec<String>, } +#[derive(Debug, Clone, Serialize)] +pub struct OrderInventoryView { + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_event_id: Option<String>, + #[serde(default)] + pub commitment_valid: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bins: Vec<OrderInventoryBinView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderInventoryBinView { + pub bin_id: String, + #[serde(default)] + pub committed_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_count: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub remaining_count: Option<u64>, + #[serde(default)] + pub over_reserved: bool, +} + impl OrderStatusView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -40,8 +40,8 @@ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ OrderDecisionView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, - OrderIssueView, OrderListView, OrderNewView, OrderStatusView, OrderSubmitView, - OrderSummaryView, OrderWatchView, RelayFailureView, + OrderInventoryBinView, OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, + OrderStatusView, OrderSubmitView, OrderSummaryView, OrderWatchView, RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -847,10 +847,12 @@ pub fn status( order_id: args.key.clone(), request_event_id: None, decision_event_id: None, + listing_event_id: None, listing_addr: None, buyer_pubkey: None, seller_pubkey: None, last_event_id: None, + inventory: None, reducer_issues: Vec::new(), target_relays: Vec::new(), connected_relays: Vec::new(), @@ -877,10 +879,12 @@ pub fn status( order_id: args.key.clone(), request_event_id: None, decision_event_id: None, + listing_event_id: None, listing_addr: None, buyer_pubkey: None, seller_pubkey: None, last_event_id: None, + inventory: None, reducer_issues: Vec::new(), target_relays, connected_relays: Vec::new(), @@ -899,7 +903,10 @@ pub fn status( } enum OrderStatusRecord { - Request(RadrootsActiveOrderRequestRecord), + Request { + listing_event_id: Option<String>, + record: RadrootsActiveOrderRequestRecord, + }, Decision(RadrootsActiveOrderDecisionRecord), } @@ -921,12 +928,17 @@ fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) - let mut skipped_count = 0usize; let mut requests = Vec::new(); let mut decisions = Vec::new(); + let mut request_listing_events = Vec::new(); let mut candidate_issues = Vec::new(); for event in events { match order_status_record_from_event(&event) { - Ok(OrderStatusRecord::Request(record)) => { + Ok(OrderStatusRecord::Request { + listing_event_id, + record, + }) => { decoded_count += 1; + request_listing_events.push((record.event_id.clone(), listing_event_id)); requests.push(record); } Ok(OrderStatusRecord::Decision(record)) => { @@ -955,7 +967,16 @@ fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) - .then_with(|| left.message.cmp(&right.message)) }); - let projection = reduce_active_order_events(order_id, requests, decisions); + let projection = reduce_active_order_events(order_id, requests, decisions.clone()); + let listing_event_id = projection + .request_event_id + .as_ref() + .and_then(|request_event_id| { + request_listing_events + .iter() + .find(|(event_id, _)| event_id == request_event_id) + .and_then(|(_, listing_event_id)| listing_event_id.clone()) + }); let mut state = active_order_status_state(&projection.status).to_owned(); let mut reason = active_order_status_reason(&projection.status, order_id); let mut reducer_issues = projection @@ -970,6 +991,13 @@ fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) - )); reducer_issues.extend(candidate_issues); } + let inventory = order_status_inventory_view( + &projection.status, + listing_event_id.clone(), + projection.decision_event_id.as_deref(), + &decisions, + reducer_issues.as_slice(), + ); OrderStatusView { state, @@ -977,10 +1005,12 @@ fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) - order_id: projection.order_id, request_event_id: projection.request_event_id, decision_event_id: projection.decision_event_id, + listing_event_id, listing_addr: projection.listing_addr, buyer_pubkey: projection.buyer_pubkey, seller_pubkey: projection.seller_pubkey, last_event_id: projection.last_event_id, + inventory, reducer_issues, target_relays, connected_relays, @@ -1055,13 +1085,14 @@ fn order_status_record_from_event( "active order request listing_addr is outside seller authority".to_owned(), )); } - Ok(OrderStatusRecord::Request( - RadrootsActiveOrderRequestRecord { + Ok(OrderStatusRecord::Request { + 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, }, - )) + }) } KIND_TRADE_ORDER_DECISION => { let event = radroots_event_from_nostr(event); @@ -1123,6 +1154,94 @@ fn active_order_status_reason( } } +fn order_status_inventory_view( + status: &RadrootsActiveOrderStatus, + listing_event_id: Option<String>, + decision_event_id: Option<&str>, + decisions: &[RadrootsActiveOrderDecisionRecord], + reducer_issues: &[OrderIssueView], +) -> Option<OrderInventoryView> { + let inventory_issues = reducer_issues + .iter() + .filter(|issue| { + matches!( + issue.code.as_str(), + "missing_decision_inventory_commitments" + | "decision_inventory_commitment_mismatch" + | "unknown_inventory_bin" + | "listing_inventory_over_reserved" + | "invalid_inventory_order" + ) + }) + .cloned() + .collect::<Vec<_>>(); + + match status { + RadrootsActiveOrderStatus::Accepted => { + let bins = decision_event_id + .and_then(|event_id| { + decisions + .iter() + .find(|decision| decision.event_id == event_id) + }) + .map(|decision| inventory_bins_from_decision(&decision.payload.decision)) + .unwrap_or_default(); + Some(OrderInventoryView { + state: if inventory_issues.is_empty() { + "reserved".to_owned() + } else { + "invalid".to_owned() + }, + listing_event_id, + commitment_valid: inventory_issues.is_empty(), + bins, + issues: inventory_issues, + }) + } + RadrootsActiveOrderStatus::Declined => Some(OrderInventoryView { + state: "not_reserved".to_owned(), + listing_event_id, + commitment_valid: true, + bins: Vec::new(), + issues: inventory_issues, + }), + RadrootsActiveOrderStatus::Invalid if !inventory_issues.is_empty() => { + Some(OrderInventoryView { + state: "invalid".to_owned(), + listing_event_id, + commitment_valid: false, + bins: Vec::new(), + issues: inventory_issues, + }) + } + _ => None, + } +} + +fn inventory_bins_from_decision( + decision: &RadrootsTradeOrderDecision, +) -> Vec<OrderInventoryBinView> { + match decision { + RadrootsTradeOrderDecision::Accepted { + inventory_commitments, + } => { + let mut bins = inventory_commitments + .iter() + .map(|commitment| OrderInventoryBinView { + bin_id: commitment.bin_id.clone(), + committed_count: u64::from(commitment.bin_count), + available_count: None, + remaining_count: None, + over_reserved: false, + }) + .collect::<Vec<_>>(); + bins.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + bins + } + RadrootsTradeOrderDecision::Declined { .. } => Vec::new(), + } +} + fn active_order_reducer_issue_view(issue_value: RadrootsActiveOrderReducerIssue) -> OrderIssueView { match issue_value { RadrootsActiveOrderReducerIssue::MissingRequest => issue_with_code( @@ -1820,7 +1939,7 @@ fn fetch_listing_accounting_decisions( } match order_status_record_from_event(&event)? { OrderStatusRecord::Decision(record) => records.push(record), - OrderStatusRecord::Request(_) => {} + OrderStatusRecord::Request { .. } => {} } } Ok(records) @@ -4338,6 +4457,10 @@ mod tests { 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()) ); @@ -4512,6 +4635,20 @@ mod tests { 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); assert!(view.reducer_issues.is_empty()); assert_eq!(view.decoded_count, 2); } @@ -4584,6 +4721,49 @@ mod tests { } #[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(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + 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); + + 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" + ); + } + + #[test] fn order_status_from_receipt_reports_declined() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( @@ -4612,6 +4792,10 @@ mod tests { 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.reducer_issues.is_empty()); assert_eq!(view.decoded_count, 2); } @@ -4953,6 +5137,7 @@ mod tests { seller: RadrootsIdentity, order_id: String, listing_addr: String, + listing_event_id: String, buyer_pubkey: String, seller_pubkey: String, request_event: radroots_nostr::prelude::RadrootsNostrEvent, @@ -4980,6 +5165,7 @@ mod tests { seller, order_id, listing_addr, + listing_event_id, buyer_pubkey, seller_pubkey, request_event, @@ -4996,7 +5182,7 @@ mod tests { order: OrderDraft { order_id: fixture.order_id.clone(), listing_addr: fixture.listing_addr.clone(), - listing_event_id: "1".repeat(64), + listing_event_id: fixture.listing_event_id.clone(), buyer_pubkey: fixture.buyer_pubkey.clone(), seller_pubkey: fixture.seller_pubkey.clone(), items: vec![OrderDraftItem {