lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 3789367dd6d41c0d7d02618c7076f5bdcc2327cd
parent abd648752ac9fc831ef15857bab1fbea7307e4ba
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 17:20:21 +0000

trade: add listing inventory accounting

- expose per-bin reservation projection types
- reduce listing-scoped active order events
- report invalid orders and over-reserved bins
- cover accepted declined invalid and over-reserved cases

Diffstat:
Mcrates/trade/src/order.rs | 505++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 502 insertions(+), 3 deletions(-)

diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -98,6 +98,57 @@ pub struct RadrootsActiveOrderProjection { pub issues: Vec<RadrootsActiveOrderReducerIssue>, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingInventoryBinAvailability { + pub bin_id: String, + pub available_count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingInventoryOrderReservation { + pub order_id: String, + pub decision_event_id: String, + pub bin_count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingInventoryBinAccounting { + pub bin_id: String, + pub available_count: u64, + pub accepted_reserved_count: u64, + pub remaining_count: u64, + pub over_reserved: bool, + pub accepted_orders: Vec<RadrootsListingInventoryOrderReservation>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsListingInventoryAccountingIssue { + InvalidActiveOrder { + order_id: String, + event_ids: Vec<String>, + }, + UnknownInventoryBin { + bin_id: String, + event_ids: Vec<String>, + }, + OverReserved { + bin_id: String, + available_count: u64, + reserved_count: u64, + event_ids: Vec<String>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingInventoryAccountingProjection { + pub listing_addr: String, + pub listing_event_id: String, + pub bins: Vec<RadrootsListingInventoryBinAccounting>, + pub declined_order_ids: Vec<String>, + pub invalid_event_ids: Vec<String>, + pub issues: Vec<RadrootsListingInventoryAccountingIssue>, +} + pub fn reduce_active_order_events<I, J>( order_id: &str, requests: I, @@ -181,6 +232,102 @@ where } } +pub fn reduce_listing_inventory_accounting<I, J, K>( + listing_addr: &str, + listing_event_id: &str, + bins: I, + requests: J, + decisions: K, +) -> RadrootsListingInventoryAccountingProjection +where + I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, + J: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, + K: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, +{ + let mut bins = normalized_listing_inventory_bins(bins); + let requests = unique_request_records(requests) + .into_iter() + .filter(|request| request.payload.listing_addr.trim() == listing_addr) + .collect::<Vec<_>>(); + let decisions = unique_decision_records(decisions) + .into_iter() + .filter(|decision| decision.payload.listing_addr.trim() == listing_addr) + .collect::<Vec<_>>(); + let mut order_ids = listing_order_ids(&requests, &decisions); + let mut declined_order_ids = Vec::new(); + let mut invalid_event_ids = Vec::new(); + let mut issues = Vec::new(); + + for order_id in order_ids.drain(..) { + let order_requests = requests + .iter() + .filter(|request| request.payload.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); + let order_decisions = decisions + .iter() + .filter(|decision| decision.payload.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); + let projection = + reduce_active_order_events(&order_id, order_requests.clone(), order_decisions.clone()); + match projection.status { + RadrootsActiveOrderStatus::Accepted => { + if let Some(decision_event_id) = projection.decision_event_id.as_deref() + && let Some(decision) = order_decisions + .iter() + .find(|decision| decision.event_id == decision_event_id) + { + add_accepted_inventory_reservations( + &mut bins, + &order_id, + decision, + &mut issues, + ); + } + } + RadrootsActiveOrderStatus::Declined => declined_order_ids.push(order_id), + RadrootsActiveOrderStatus::Invalid => { + let mut event_ids = projection_issue_event_ids(&projection.issues); + if event_ids.is_empty() { + event_ids.extend( + order_requests + .iter() + .map(|request| request.event_id.clone()), + ); + event_ids.extend( + order_decisions + .iter() + .map(|decision| decision.event_id.clone()), + ); + sort_and_dedup_strings(&mut event_ids); + } + invalid_event_ids.extend(event_ids.iter().cloned()); + issues.push( + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { + order_id, + event_ids, + }, + ); + } + RadrootsActiveOrderStatus::Missing | RadrootsActiveOrderStatus::Requested => {} + } + } + + sort_and_dedup_strings(&mut declined_order_ids); + sort_and_dedup_strings(&mut invalid_event_ids); + finish_inventory_accounting_bins(&mut bins, &mut issues); + issues.sort_by(inventory_issue_sort_key); + RadrootsListingInventoryAccountingProjection { + listing_addr: listing_addr.to_string(), + listing_event_id: listing_event_id.to_string(), + bins, + declined_order_ids, + invalid_event_ids, + issues, + } +} + pub fn canonicalize_order_request_for_signer( mut order: TradeOrder, signer_pubkey: &str, @@ -345,6 +492,201 @@ where unique } +fn normalized_listing_inventory_bins<I>(bins: I) -> Vec<RadrootsListingInventoryBinAccounting> +where + I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, +{ + let mut normalized: Vec<RadrootsListingInventoryBinAccounting> = Vec::new(); + for bin in bins { + let bin_id = bin.bin_id.trim(); + if bin_id.is_empty() { + continue; + } + if let Some(existing) = normalized + .iter_mut() + .find(|existing| existing.bin_id == bin_id) + { + existing.available_count += bin.available_count; + } else { + normalized.push(RadrootsListingInventoryBinAccounting { + bin_id: bin_id.to_string(), + available_count: bin.available_count, + accepted_reserved_count: 0, + remaining_count: bin.available_count, + over_reserved: false, + accepted_orders: Vec::new(), + }); + } + } + normalized.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + normalized +} + +fn listing_order_ids( + requests: &[RadrootsActiveOrderRequestRecord], + decisions: &[RadrootsActiveOrderDecisionRecord], +) -> Vec<String> { + let mut order_ids = Vec::new(); + order_ids.extend( + requests + .iter() + .map(|request| request.payload.order_id.clone()), + ); + order_ids.extend( + decisions + .iter() + .map(|decision| decision.payload.order_id.clone()), + ); + sort_and_dedup_strings(&mut order_ids); + order_ids +} + +fn add_accepted_inventory_reservations( + bins: &mut [RadrootsListingInventoryBinAccounting], + order_id: &str, + decision: &RadrootsActiveOrderDecisionRecord, + issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, +) { + let RadrootsTradeOrderDecision::Accepted { + inventory_commitments, + } = &decision.payload.decision + else { + return; + }; + let Some(commitments) = normalized_inventory_commitment_counts(inventory_commitments) else { + issues.push( + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { + order_id: order_id.to_string(), + event_ids: vec![decision.event_id.clone()], + }, + ); + return; + }; + for commitment in commitments { + if let Some(bin) = bins.iter_mut().find(|bin| bin.bin_id == commitment.bin_id) { + bin.accepted_reserved_count += commitment.bin_count; + bin.accepted_orders + .push(RadrootsListingInventoryOrderReservation { + order_id: order_id.to_string(), + decision_event_id: decision.event_id.clone(), + bin_count: commitment.bin_count, + }); + } else { + issues.push( + RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + bin_id: commitment.bin_id, + event_ids: vec![decision.event_id.clone()], + }, + ); + } + } +} + +fn finish_inventory_accounting_bins( + bins: &mut [RadrootsListingInventoryBinAccounting], + issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, +) { + for bin in bins.iter_mut() { + bin.accepted_orders.sort_by(|left, right| { + left.order_id + .cmp(&right.order_id) + .then_with(|| left.decision_event_id.cmp(&right.decision_event_id)) + }); + bin.remaining_count = bin + .available_count + .saturating_sub(bin.accepted_reserved_count); + bin.over_reserved = bin.accepted_reserved_count > bin.available_count; + if bin.over_reserved { + let mut event_ids = bin + .accepted_orders + .iter() + .map(|reservation| reservation.decision_event_id.clone()) + .collect::<Vec<_>>(); + sort_and_dedup_strings(&mut event_ids); + issues.push(RadrootsListingInventoryAccountingIssue::OverReserved { + bin_id: bin.bin_id.clone(), + available_count: bin.available_count, + reserved_count: bin.accepted_reserved_count, + event_ids, + }); + } + } + bins.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); +} + +fn projection_issue_event_ids(issues: &[RadrootsActiveOrderReducerIssue]) -> Vec<String> { + let mut event_ids = Vec::new(); + for issue in issues { + match issue { + RadrootsActiveOrderReducerIssue::MissingRequest => {} + RadrootsActiveOrderReducerIssue::MultipleRequests { event_ids: ids } + | RadrootsActiveOrderReducerIssue::ConflictingDecisions { event_ids: ids } => { + event_ids.extend(ids.iter().cloned()); + } + RadrootsActiveOrderReducerIssue::RequestPayloadInvalid { event_id } + | RadrootsActiveOrderReducerIssue::RequestOrderIdMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RequestAuthorMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RequestListingAddressInvalid { event_id } + | RadrootsActiveOrderReducerIssue::RequestSellerListingMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionPayloadInvalid { event_id } + | RadrootsActiveOrderReducerIssue::DecisionOrderIdMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionAuthorMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionBuyerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionSellerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionListingAddressInvalid { event_id } + | RadrootsActiveOrderReducerIssue::DecisionListingMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionRootMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionPreviousMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionMissingInventoryCommitments { event_id } + | RadrootsActiveOrderReducerIssue::DecisionInventoryCommitmentMismatch { event_id } + | RadrootsActiveOrderReducerIssue::DecisionMissingReason { event_id } => { + event_ids.push(event_id.clone()); + } + } + } + sort_and_dedup_strings(&mut event_ids); + event_ids +} + +fn sort_and_dedup_strings(values: &mut Vec<String>) { + values.sort(); + values.dedup(); +} + +fn inventory_issue_sort_key( + left: &RadrootsListingInventoryAccountingIssue, + right: &RadrootsListingInventoryAccountingIssue, +) -> core::cmp::Ordering { + inventory_issue_rank(left) + .cmp(&inventory_issue_rank(right)) + .then_with(|| inventory_issue_id(left).cmp(inventory_issue_id(right))) + .then_with(|| inventory_issue_event_ids(left).cmp(inventory_issue_event_ids(right))) +} + +fn inventory_issue_rank(issue: &RadrootsListingInventoryAccountingIssue) -> u8 { + match issue { + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { .. } => 0, + RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { .. } => 1, + RadrootsListingInventoryAccountingIssue::OverReserved { .. } => 2, + } +} + +fn inventory_issue_id(issue: &RadrootsListingInventoryAccountingIssue) -> &str { + match issue { + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { order_id, .. } => order_id, + RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { bin_id, .. } + | RadrootsListingInventoryAccountingIssue::OverReserved { bin_id, .. } => bin_id, + } +} + +fn inventory_issue_event_ids(issue: &RadrootsListingInventoryAccountingIssue) -> &[String] { + match issue { + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { event_ids, .. } + | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { event_ids, .. } + | RadrootsListingInventoryAccountingIssue::OverReserved { event_ids, .. } => event_ids, + } +} + fn validate_active_request_record( order_id: &str, request: &RadrootsActiveOrderRequestRecord, @@ -706,9 +1048,11 @@ mod tests { use super::{ RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, RadrootsActiveOrderStatus, + RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryBinAccounting, + RadrootsListingInventoryBinAvailability, RadrootsListingInventoryOrderReservation, RadrootsTradeOrderCanonicalizationError, canonicalize_active_order_decision_for_signer, canonicalize_active_order_request_for_signer, canonicalize_order_request_for_signer, - reduce_active_order_events, + reduce_active_order_events, reduce_listing_inventory_accounting, }; const SELLER: &str = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -756,10 +1100,14 @@ mod tests { } } + fn listing_addr() -> String { + format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + } + fn clean_request_payload() -> RadrootsTradeOrderRequested { RadrootsTradeOrderRequested { order_id: "order-1".to_string(), - listing_addr: format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"), + listing_addr: listing_addr(), buyer_pubkey: BUYER.to_string(), seller_pubkey: SELLER.to_string(), items: vec![RadrootsTradeOrderItem { @@ -781,10 +1129,21 @@ mod tests { request_record_with_event_id("request-1") } + fn request_record_for( + order_id: &str, + event_id: &str, + bin_count: u32, + ) -> RadrootsActiveOrderRequestRecord { + let mut request = request_record_with_event_id(event_id); + request.payload.order_id = order_id.to_string(); + request.payload.items[0].bin_count = bin_count; + request + } + fn decision_payload(decision: RadrootsTradeOrderDecision) -> RadrootsTradeOrderDecisionEvent { RadrootsTradeOrderDecisionEvent { order_id: "order-1".to_string(), - listing_addr: format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"), + listing_addr: listing_addr(), buyer_pubkey: BUYER.to_string(), seller_pubkey: SELLER.to_string(), decision, @@ -818,6 +1177,33 @@ mod tests { } } + fn accepted_decision_record_for( + order_id: &str, + event_id: &str, + request_event_id: &str, + bin_count: u32, + ) -> RadrootsActiveOrderDecisionRecord { + let mut decision = accepted_decision_record(event_id); + decision.root_event_id = request_event_id.to_string(); + decision.prev_event_id = request_event_id.to_string(); + decision.payload.order_id = order_id.to_string(); + let RadrootsTradeOrderDecision::Accepted { + inventory_commitments, + } = &mut decision.payload.decision + else { + panic!("expected accepted decision") + }; + inventory_commitments[0].bin_count = bin_count; + decision + } + + fn inventory_bin(available_count: u64) -> RadrootsListingInventoryBinAvailability { + RadrootsListingInventoryBinAvailability { + bin_id: "bin-1".to_string(), + available_count, + } + } + #[test] fn canonicalize_order_request_sets_missing_pubkeys() { let order = canonicalize_order_request_for_signer(base_order("", ""), SELLER) @@ -960,6 +1346,119 @@ mod tests { } #[test] + fn reduce_listing_inventory_accounting_reserves_accepted_inventory() { + let projection = reduce_listing_inventory_accounting( + &listing_addr(), + "listing-event-1", + [inventory_bin(5)], + [request_record()], + [accepted_decision_record("decision-1")], + ); + + assert_eq!(projection.listing_event_id, "listing-event-1"); + assert_eq!(projection.declined_order_ids, Vec::<String>::new()); + assert_eq!(projection.invalid_event_ids, Vec::<String>::new()); + assert!(projection.issues.is_empty()); + assert_eq!( + projection.bins, + vec![RadrootsListingInventoryBinAccounting { + bin_id: "bin-1".to_string(), + available_count: 5, + accepted_reserved_count: 2, + remaining_count: 3, + over_reserved: false, + accepted_orders: vec![RadrootsListingInventoryOrderReservation { + order_id: "order-1".to_string(), + decision_event_id: "decision-1".to_string(), + bin_count: 2, + }], + }] + ); + } + + #[test] + fn reduce_listing_inventory_accounting_leaves_declined_inventory_available() { + let projection = reduce_listing_inventory_accounting( + &listing_addr(), + "listing-event-1", + [inventory_bin(5)], + [request_record()], + [declined_decision_record("decision-1")], + ); + + assert_eq!(projection.declined_order_ids, vec!["order-1".to_string()]); + assert!(projection.invalid_event_ids.is_empty()); + assert!(projection.issues.is_empty()); + assert_eq!(projection.bins[0].accepted_reserved_count, 0); + assert_eq!(projection.bins[0].remaining_count, 5); + assert!(!projection.bins[0].over_reserved); + } + + #[test] + fn reduce_listing_inventory_accounting_reports_invalid_mismatched_commitment() { + let decision = RadrootsActiveOrderDecisionRecord { + payload: decision_payload(RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_string(), + bin_count: 1, + }], + }), + ..accepted_decision_record("decision-1") + }; + + let projection = reduce_listing_inventory_accounting( + &listing_addr(), + "listing-event-1", + [inventory_bin(5)], + [request_record()], + [decision], + ); + + assert_eq!(projection.bins[0].accepted_reserved_count, 0); + assert_eq!(projection.invalid_event_ids, vec!["decision-1".to_string()]); + assert_eq!( + projection.issues, + vec![ + RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { + order_id: "order-1".to_string(), + event_ids: vec!["decision-1".to_string()], + } + ] + ); + } + + #[test] + fn reduce_listing_inventory_accounting_reports_over_reserved_bins() { + let projection = reduce_listing_inventory_accounting( + &listing_addr(), + "listing-event-1", + [inventory_bin(3)], + [ + request_record_for("order-2", "request-2", 2), + request_record_for("order-1", "request-1", 2), + ], + [ + accepted_decision_record_for("order-2", "decision-2", "request-2", 2), + accepted_decision_record_for("order-1", "decision-1", "request-1", 2), + ], + ); + + assert_eq!(projection.bins[0].available_count, 3); + assert_eq!(projection.bins[0].accepted_reserved_count, 4); + assert_eq!(projection.bins[0].remaining_count, 0); + assert!(projection.bins[0].over_reserved); + assert_eq!( + projection.issues, + vec![RadrootsListingInventoryAccountingIssue::OverReserved { + bin_id: "bin-1".to_string(), + available_count: 3, + reserved_count: 4, + event_ids: vec!["decision-1".to_string(), "decision-2".to_string()], + }] + ); + } + + #[test] fn reduce_active_order_events_rejects_invalid_decision_actor() { let mut decision = accepted_decision_record("decision-1"); decision.author_pubkey = BUYER.to_string();