lib

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

commit ddbb7a450c86156fb735c7dfa7260254c15a0dbc
parent 929b814fe4ddc8902da0936aded10ff50fbde712
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 16:04:12 +0000

trade: add active order reducer

- add active order request and decision reducer records
- derive missing requested accepted declined and invalid states
- reject actor chain commitment reason and conflict errors
- cover reducer states with radroots_trade unit tests

Diffstat:
Mcrates/trade/src/order.rs | 587++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 586 insertions(+), 1 deletion(-)

diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -1,7 +1,10 @@ #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] -use alloc::string::{String, ToString}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::kinds::KIND_LISTING; use radroots_events::trade::{ @@ -33,6 +36,150 @@ pub enum RadrootsTradeOrderCanonicalizationError { InvalidInventoryCommitmentCount { index: usize }, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderRequestRecord { + pub event_id: String, + pub author_pubkey: String, + pub payload: RadrootsTradeOrderRequested, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderDecisionRecord { + pub event_id: String, + pub author_pubkey: String, + pub root_event_id: String, + pub prev_event_id: String, + pub payload: RadrootsTradeOrderDecisionEvent, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsActiveOrderStatus { + Missing, + Requested, + Accepted, + Declined, + Invalid, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsActiveOrderReducerIssue { + MissingRequest, + MultipleRequests { event_ids: Vec<String> }, + RequestPayloadInvalid { event_id: String }, + RequestOrderIdMismatch { event_id: String }, + RequestAuthorMismatch { event_id: String }, + RequestListingAddressInvalid { event_id: String }, + RequestSellerListingMismatch { event_id: String }, + DecisionPayloadInvalid { event_id: String }, + DecisionOrderIdMismatch { event_id: String }, + DecisionAuthorMismatch { event_id: String }, + DecisionBuyerMismatch { event_id: String }, + DecisionSellerMismatch { event_id: String }, + DecisionListingAddressInvalid { event_id: String }, + DecisionListingMismatch { event_id: String }, + DecisionRootMismatch { event_id: String }, + DecisionPreviousMismatch { event_id: String }, + DecisionMissingInventoryCommitments { event_id: String }, + DecisionMissingReason { event_id: String }, + ConflictingDecisions { event_ids: Vec<String> }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderProjection { + pub order_id: String, + pub status: RadrootsActiveOrderStatus, + pub request_event_id: Option<String>, + pub decision_event_id: Option<String>, + pub listing_addr: Option<String>, + pub buyer_pubkey: Option<String>, + pub seller_pubkey: Option<String>, + pub last_event_id: Option<String>, + pub issues: Vec<RadrootsActiveOrderReducerIssue>, +} + +pub fn reduce_active_order_events<I, J>( + order_id: &str, + requests: I, + decisions: J, +) -> RadrootsActiveOrderProjection +where + I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, + J: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, +{ + let requests = unique_request_records(requests); + let decisions = unique_decision_records(decisions); + if requests.is_empty() && decisions.is_empty() { + return RadrootsActiveOrderProjection { + order_id: order_id.to_string(), + status: RadrootsActiveOrderStatus::Missing, + request_event_id: None, + decision_event_id: None, + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + last_event_id: None, + issues: Vec::new(), + }; + } + + let mut issues = Vec::new(); + let mut valid_requests = Vec::new(); + for request in requests { + if validate_active_request_record(order_id, &request, &mut issues) { + valid_requests.push(request); + } + } + + if valid_requests.len() > 1 { + let mut event_ids = valid_requests + .iter() + .map(|request| request.event_id.clone()) + .collect::<Vec<_>>(); + event_ids.sort(); + issues.push(RadrootsActiveOrderReducerIssue::MultipleRequests { event_ids }); + } + + let Some(request) = valid_requests.first() else { + if decisions.is_empty() { + return invalid_projection(order_id, None, issues); + } + issues.push(RadrootsActiveOrderReducerIssue::MissingRequest); + return invalid_projection(order_id, None, issues); + }; + + if valid_requests.len() > 1 { + return invalid_projection(order_id, Some(request), issues); + } + + let mut valid_decisions = Vec::new(); + for decision in decisions { + if validate_active_decision_record(request, &decision, &mut issues) { + valid_decisions.push(decision); + } + } + + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + + match valid_decisions.len() { + 0 => requested_projection(order_id, request), + 1 => decided_projection(order_id, request, &valid_decisions[0]), + _ => { + let mut event_ids = valid_decisions + .iter() + .map(|decision| decision.event_id.clone()) + .collect::<Vec<_>>(); + event_ids.sort(); + invalid_projection( + order_id, + Some(request), + vec![RadrootsActiveOrderReducerIssue::ConflictingDecisions { event_ids }], + ) + } + } +} + pub fn canonicalize_order_request_for_signer( mut order: TradeOrder, signer_pubkey: &str, @@ -157,6 +304,253 @@ pub fn canonicalize_active_order_decision_for_signer( Ok(decision_event) } +fn unique_request_records<I>(requests: I) -> Vec<RadrootsActiveOrderRequestRecord> +where + I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, +{ + let mut unique = Vec::new(); + for request in requests { + if unique + .iter() + .all(|existing: &RadrootsActiveOrderRequestRecord| { + existing.event_id != request.event_id + }) + { + unique.push(request); + } + } + unique +} + +fn unique_decision_records<I>(decisions: I) -> Vec<RadrootsActiveOrderDecisionRecord> +where + I: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, +{ + let mut unique = Vec::new(); + for decision in decisions { + if unique + .iter() + .all(|existing: &RadrootsActiveOrderDecisionRecord| { + existing.event_id != decision.event_id + }) + { + unique.push(decision); + } + } + unique +} + +fn validate_active_request_record( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + let mut valid = true; + if request.payload.validate().is_err() { + issues.push(RadrootsActiveOrderReducerIssue::RequestPayloadInvalid { + event_id: request.event_id.clone(), + }); + valid = false; + } + if request.payload.order_id != order_id { + issues.push(RadrootsActiveOrderReducerIssue::RequestOrderIdMismatch { + event_id: request.event_id.clone(), + }); + valid = false; + } + if request.author_pubkey != request.payload.buyer_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::RequestAuthorMismatch { + event_id: request.event_id.clone(), + }); + valid = false; + } + match parse_public_listing_addr(&request.payload.listing_addr) { + Ok(listing_addr) => { + if listing_addr.seller_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RequestSellerListingMismatch { + event_id: request.event_id.clone(), + }, + ); + valid = false; + } + } + Err(_) => { + issues.push( + RadrootsActiveOrderReducerIssue::RequestListingAddressInvalid { + event_id: request.event_id.clone(), + }, + ); + valid = false; + } + } + valid +} + +fn validate_active_decision_record( + request: &RadrootsActiveOrderRequestRecord, + decision: &RadrootsActiveOrderDecisionRecord, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + let mut valid = true; + if decision_payload_issue(&decision.payload.decision, &decision.event_id, issues) { + valid = false; + } + if decision.payload.validate().is_err() { + issues.push(RadrootsActiveOrderReducerIssue::DecisionPayloadInvalid { + event_id: decision.event_id.clone(), + }); + valid = false; + } + if decision.payload.order_id != request.payload.order_id { + issues.push(RadrootsActiveOrderReducerIssue::DecisionOrderIdMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + if decision.author_pubkey != decision.payload.seller_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::DecisionAuthorMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + if decision.payload.buyer_pubkey != request.payload.buyer_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::DecisionBuyerMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + if decision.payload.seller_pubkey != request.payload.seller_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::DecisionSellerMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + match parse_public_listing_addr(&decision.payload.listing_addr) { + Ok(listing_addr) => { + if decision.payload.listing_addr != request.payload.listing_addr + || listing_addr.seller_pubkey != decision.payload.seller_pubkey + { + issues.push(RadrootsActiveOrderReducerIssue::DecisionListingMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + } + Err(_) => { + issues.push( + RadrootsActiveOrderReducerIssue::DecisionListingAddressInvalid { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + } + if decision.root_event_id != request.event_id { + issues.push(RadrootsActiveOrderReducerIssue::DecisionRootMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + if decision.prev_event_id != request.event_id { + issues.push(RadrootsActiveOrderReducerIssue::DecisionPreviousMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; + } + valid +} + +fn decision_payload_issue( + decision: &RadrootsTradeOrderDecision, + event_id: &str, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + match decision { + RadrootsTradeOrderDecision::Accepted { + inventory_commitments, + } => { + if inventory_commitments.is_empty() { + issues.push( + RadrootsActiveOrderReducerIssue::DecisionMissingInventoryCommitments { + event_id: event_id.to_string(), + }, + ); + true + } else { + false + } + } + RadrootsTradeOrderDecision::Declined { reason } => { + if reason.trim().is_empty() { + issues.push(RadrootsActiveOrderReducerIssue::DecisionMissingReason { + event_id: event_id.to_string(), + }); + true + } else { + false + } + } + } +} + +fn requested_projection( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, +) -> RadrootsActiveOrderProjection { + RadrootsActiveOrderProjection { + order_id: order_id.to_string(), + status: RadrootsActiveOrderStatus::Requested, + request_event_id: Some(request.event_id.clone()), + decision_event_id: None, + listing_addr: Some(request.payload.listing_addr.clone()), + buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), + seller_pubkey: Some(request.payload.seller_pubkey.clone()), + last_event_id: Some(request.event_id.clone()), + issues: Vec::new(), + } +} + +fn decided_projection( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, + decision: &RadrootsActiveOrderDecisionRecord, +) -> RadrootsActiveOrderProjection { + let status = match &decision.payload.decision { + RadrootsTradeOrderDecision::Accepted { .. } => RadrootsActiveOrderStatus::Accepted, + RadrootsTradeOrderDecision::Declined { .. } => RadrootsActiveOrderStatus::Declined, + }; + RadrootsActiveOrderProjection { + order_id: order_id.to_string(), + status, + request_event_id: Some(request.event_id.clone()), + decision_event_id: Some(decision.event_id.clone()), + listing_addr: Some(request.payload.listing_addr.clone()), + buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), + seller_pubkey: Some(request.payload.seller_pubkey.clone()), + last_event_id: Some(decision.event_id.clone()), + issues: Vec::new(), + } +} + +fn invalid_projection( + order_id: &str, + request: Option<&RadrootsActiveOrderRequestRecord>, + issues: Vec<RadrootsActiveOrderReducerIssue>, +) -> RadrootsActiveOrderProjection { + RadrootsActiveOrderProjection { + order_id: order_id.to_string(), + status: RadrootsActiveOrderStatus::Invalid, + request_event_id: request.map(|request| request.event_id.clone()), + decision_event_id: None, + listing_addr: request.map(|request| request.payload.listing_addr.clone()), + buyer_pubkey: request.map(|request| request.payload.buyer_pubkey.clone()), + seller_pubkey: request.map(|request| request.payload.seller_pubkey.clone()), + last_event_id: request.map(|request| request.event_id.clone()), + issues, + } +} + fn parse_public_listing_addr( listing_addr_raw: &str, ) -> Result<TradeListingAddress, RadrootsTradeOrderCanonicalizationError> { @@ -236,8 +630,11 @@ mod tests { }; use super::{ + RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderReducerIssue, + RadrootsActiveOrderRequestRecord, RadrootsActiveOrderStatus, RadrootsTradeOrderCanonicalizationError, canonicalize_active_order_decision_for_signer, canonicalize_active_order_request_for_signer, canonicalize_order_request_for_signer, + reduce_active_order_events, }; const SELLER: &str = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -285,6 +682,64 @@ mod tests { } } + fn clean_request_payload() -> RadrootsTradeOrderRequested { + RadrootsTradeOrderRequested { + order_id: "order-1".to_string(), + listing_addr: format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"), + buyer_pubkey: BUYER.to_string(), + seller_pubkey: SELLER.to_string(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_string(), + bin_count: 2, + }], + } + } + + fn request_record() -> RadrootsActiveOrderRequestRecord { + RadrootsActiveOrderRequestRecord { + event_id: "request-1".to_string(), + author_pubkey: BUYER.to_string(), + payload: clean_request_payload(), + } + } + + fn decision_payload(decision: RadrootsTradeOrderDecision) -> RadrootsTradeOrderDecisionEvent { + RadrootsTradeOrderDecisionEvent { + order_id: "order-1".to_string(), + listing_addr: format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"), + buyer_pubkey: BUYER.to_string(), + seller_pubkey: SELLER.to_string(), + decision, + } + } + + fn accepted_decision_record(event_id: &str) -> RadrootsActiveOrderDecisionRecord { + RadrootsActiveOrderDecisionRecord { + event_id: event_id.to_string(), + author_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: "request-1".to_string(), + payload: decision_payload(RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_string(), + bin_count: 2, + }], + }), + } + } + + fn declined_decision_record(event_id: &str) -> RadrootsActiveOrderDecisionRecord { + RadrootsActiveOrderDecisionRecord { + event_id: event_id.to_string(), + author_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: "request-1".to_string(), + payload: decision_payload(RadrootsTradeOrderDecision::Declined { + reason: "out_of_stock".to_string(), + }), + } + } + #[test] fn canonicalize_order_request_sets_missing_pubkeys() { let order = canonicalize_order_request_for_signer(base_order("", ""), SELLER) @@ -383,4 +838,134 @@ mod tests { }; assert_eq!(reason, "out_of_stock"); } + + #[test] + fn reduce_active_order_events_reports_missing_without_events() { + let projection = reduce_active_order_events("order-1", [], []); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Missing); + assert!(projection.issues.is_empty()); + } + + #[test] + fn reduce_active_order_events_reports_requested_state() { + let projection = reduce_active_order_events("order-1", [request_record()], []); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Requested); + assert_eq!(projection.request_event_id.as_deref(), Some("request-1")); + assert_eq!(projection.last_event_id.as_deref(), Some("request-1")); + } + + #[test] + fn reduce_active_order_events_reports_accepted_state() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); + assert_eq!(projection.decision_event_id.as_deref(), Some("decision-1")); + assert_eq!(projection.last_event_id.as_deref(), Some("decision-1")); + } + + #[test] + fn reduce_active_order_events_reports_declined_state() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [declined_decision_record("decision-1")], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Declined); + assert_eq!(projection.decision_event_id.as_deref(), Some("decision-1")); + } + + #[test] + fn reduce_active_order_events_rejects_invalid_decision_actor() { + let mut decision = accepted_decision_record("decision-1"); + decision.author_pubkey = BUYER.to_string(); + + let projection = reduce_active_order_events("order-1", [request_record()], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::DecisionAuthorMismatch { event_id } + if event_id == "decision-1" + ))); + } + + #[test] + fn reduce_active_order_events_rejects_invalid_decision_chain() { + let mut decision = accepted_decision_record("decision-1"); + decision.prev_event_id = "request-2".to_string(); + + let projection = reduce_active_order_events("order-1", [request_record()], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::DecisionPreviousMismatch { event_id } + if event_id == "decision-1" + ))); + } + + #[test] + fn reduce_active_order_events_rejects_missing_commitment() { + let decision = RadrootsActiveOrderDecisionRecord { + payload: decision_payload(RadrootsTradeOrderDecision::Accepted { + inventory_commitments: Vec::new(), + }), + ..accepted_decision_record("decision-1") + }; + + let projection = reduce_active_order_events("order-1", [request_record()], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::DecisionMissingInventoryCommitments { event_id } + if event_id == "decision-1" + ))); + } + + #[test] + fn reduce_active_order_events_rejects_missing_decline_reason() { + let decision = RadrootsActiveOrderDecisionRecord { + payload: decision_payload(RadrootsTradeOrderDecision::Declined { + reason: " ".to_string(), + }), + ..declined_decision_record("decision-1") + }; + + let projection = reduce_active_order_events("order-1", [request_record()], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::DecisionMissingReason { event_id } + if event_id == "decision-1" + ))); + } + + #[test] + fn reduce_active_order_events_rejects_conflicting_decisions() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [ + accepted_decision_record("decision-2"), + declined_decision_record("decision-1"), + ], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert_eq!( + projection.issues, + vec![RadrootsActiveOrderReducerIssue::ConflictingDecisions { + event_ids: vec!["decision-1".to_string(), "decision-2".to_string()] + }] + ); + } }