lib

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

commit bfc315a1cb4f4c811204fd72a5f0a05a83d5692a
parent dae8880dd03a6d2be057b4e8083f881d926fba7c
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 08:02:58 +0000

events: add order revision decisions

Diffstat:
Mcrates/events/src/trade.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/trade/decode.rs | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/events_codec/src/trade/encode.rs | 27++++++++++++++++++++++++++-
Mcrates/events_codec/src/trade/mod.rs | 9+++++----
Mcrates/trade/src/order.rs | 1252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
5 files changed, 1295 insertions(+), 173 deletions(-)

diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -475,6 +475,53 @@ impl RadrootsTradeOrderRevisionProposed { #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeOrderRevisionDecision { + Accepted, + Declined { reason: String }, +} + +impl RadrootsTradeOrderRevisionDecision { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + match self { + Self::Accepted => Ok(()), + Self::Declined { reason } => validate_required_field(reason, "reason"), + } + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderRevisionDecisionEvent { + pub revision_id: String, + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub root_event_id: String, + pub prev_event_id: String, + pub decision: RadrootsTradeOrderRevisionDecision, +} + +impl RadrootsTradeOrderRevisionDecisionEvent { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + validate_required_field(&self.revision_id, "revision_id")?; + validate_required_field(&self.order_id, "order_id")?; + validate_required_field(&self.listing_addr, "listing_addr")?; + validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; + validate_required_field(&self.seller_pubkey, "seller_pubkey")?; + validate_required_field(&self.root_event_id, "root_event_id")?; + validate_required_field(&self.prev_event_id, "prev_event_id")?; + self.decision.validate() + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeInventoryCommitment { pub bin_id: String, diff --git a/crates/events_codec/src/trade/decode.rs b/crates/events_codec/src/trade/decode.rs @@ -11,7 +11,8 @@ use radroots_events::{ RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeBuyerReceipt, RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessageType, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecisionEvent, - RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionProposed, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecisionEvent, + RadrootsTradeOrderRevisionProposed, }, }; #[cfg(feature = "serde_json")] @@ -419,6 +420,45 @@ pub fn active_trade_order_revision_proposal_from_event( } #[cfg(feature = "serde_json")] +pub fn active_trade_order_revision_decision_from_event( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradeOrderRevisionDecisionEvent>, + RadrootsActiveTradeEnvelopeParseError, +> { + let envelope = + active_trade_envelope_from_event::<RadrootsTradeOrderRevisionDecisionEvent>(event)?; + if envelope.message_type != RadrootsActiveTradeMessageType::TradeOrderRevisionDecision { + return Err( + RadrootsActiveTradeEnvelopeParseError::MessageTypeKindMismatch { + event_kind: event.kind, + message_type: envelope.message_type, + }, + ); + } + envelope + .payload + .validate() + .map_err(RadrootsActiveTradeEnvelopeParseError::InvalidPayload)?; + validate_active_order_binding( + event, + &envelope, + &envelope.payload.order_id, + &envelope.payload.listing_addr, + &envelope.payload.buyer_pubkey, + &envelope.payload.seller_pubkey, + )?; + let context = active_trade_event_context_from_tags(envelope.message_type, &event.tags)?; + if context.root_event_id.as_deref() != Some(envelope.payload.root_event_id.as_str()) { + return Err(RadrootsActiveTradeEnvelopeParseError::PayloadBindingMismatch("root_event_id")); + } + if context.prev_event_id.as_deref() != Some(envelope.payload.prev_event_id.as_str()) { + return Err(RadrootsActiveTradeEnvelopeParseError::PayloadBindingMismatch("prev_event_id")); + } + Ok(envelope) +} + +#[cfg(feature = "serde_json")] pub fn active_trade_fulfillment_update_from_event( event: &RadrootsNostrEvent, ) -> Result< @@ -681,14 +721,15 @@ mod tests { RadrootsTradeListingAddress, active_trade_buyer_receipt_from_event, active_trade_envelope_from_event, active_trade_fulfillment_update_from_event, active_trade_order_cancel_from_event, active_trade_order_decision_from_event, - active_trade_order_request_from_event, active_trade_order_revision_proposal_from_event, - trade_envelope_from_event, trade_event_context_from_tags, + active_trade_order_request_from_event, active_trade_order_revision_decision_from_event, + active_trade_order_revision_proposal_from_event, trade_envelope_from_event, + trade_event_context_from_tags, }; use crate::trade::encode::{ active_trade_buyer_receipt_event_build, active_trade_fulfillment_update_event_build, active_trade_order_cancel_event_build, active_trade_order_decision_event_build, - active_trade_order_request_event_build, active_trade_order_revision_proposal_event_build, - trade_envelope_event_build, + active_trade_order_request_event_build, active_trade_order_revision_decision_event_build, + active_trade_order_revision_proposal_event_build, trade_envelope_event_build, }; use crate::trade::tags::TAG_LISTING_EVENT; use radroots_core::{ @@ -711,6 +752,7 @@ mod tests { RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis, }, }; @@ -816,6 +858,21 @@ mod tests { } } + fn active_order_revision_decision( + decision: RadrootsTradeOrderRevisionDecision, + ) -> RadrootsTradeOrderRevisionDecisionEvent { + RadrootsTradeOrderRevisionDecisionEvent { + revision_id: "rev-1".into(), + order_id: "order-1".into(), + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + root_event_id: "root-event".into(), + prev_event_id: "revision-event".into(), + decision, + } + } + fn active_fulfillment_update() -> RadrootsTradeFulfillmentUpdated { RadrootsTradeFulfillmentUpdated { order_id: "order-1".into(), @@ -1009,6 +1066,43 @@ mod tests { } #[test] + fn active_order_revision_decision_builder_emits_canonical_chain_shape() { + let payload = active_order_revision_decision(RadrootsTradeOrderRevisionDecision::Accepted); + let built = active_trade_order_revision_decision_event_build( + payload.root_event_id.as_str(), + payload.prev_event_id.as_str(), + &payload, + ) + .unwrap(); + let envelope: RadrootsActiveTradeEnvelope<RadrootsTradeOrderRevisionDecisionEvent> = + serde_json::from_str(&built.content).unwrap(); + + assert_eq!(built.kind, KIND_TRADE_ORDER_REVISION_RESPONSE); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeOrderRevisionDecision + ); + assert_eq!(built.tags[0], vec!["p".to_string(), "seller".to_string()]); + assert_eq!( + built.tags[2], + vec![TAG_D.to_string(), "order-1".to_string()] + ); + assert_eq!(envelope.payload.revision_id, "rev-1"); + assert!( + built + .tags + .iter() + .any(|tag| tag == &vec![TAG_E_ROOT.to_string(), "root-event".to_string()]) + ); + assert!( + built + .tags + .iter() + .any(|tag| tag == &vec![TAG_E_PREV.to_string(), "revision-event".to_string()]) + ); + } + + #[test] fn active_fulfillment_update_builder_emits_canonical_chain_shape() { let payload = active_fulfillment_update(); let built = @@ -1282,6 +1376,35 @@ mod tests { } #[test] + fn active_order_revision_decision_parse_validates_actor_counterparty_and_chain_payload() { + let payload = + active_order_revision_decision(RadrootsTradeOrderRevisionDecision::Declined { + reason: "no change".into(), + }); + let built = active_trade_order_revision_decision_event_build( + payload.root_event_id.as_str(), + payload.prev_event_id.as_str(), + &payload, + ) + .unwrap(); + let mut event = RadrootsNostrEvent { + id: "event-id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope = active_trade_order_revision_decision_from_event(&event).unwrap(); + assert_eq!(envelope.payload, payload); + + event.author = "seller".into(); + let err = active_trade_order_revision_decision_from_event(&event).unwrap_err(); + assert_eq!(err, RadrootsActiveTradeEnvelopeParseError::AuthorMismatch); + } + + #[test] fn active_revision_kinds_parse_with_chain_tags() { for (kind, message_type) in [ ( diff --git a/crates/events_codec/src/trade/encode.rs b/crates/events_codec/src/trade/encode.rs @@ -10,7 +10,7 @@ use radroots_events::{ RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, - RadrootsTradeOrderRevisionProposed, + RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, }, }; @@ -258,6 +258,31 @@ pub fn active_trade_order_revision_proposal_event_build( } #[cfg(feature = "serde_json")] +pub fn active_trade_order_revision_decision_event_build( + root_event_id: &str, + prev_event_id: &str, + payload: &RadrootsTradeOrderRevisionDecisionEvent, +) -> Result<WireEventParts, EventEncodeError> { + payload.validate().map_err(map_active_payload_error)?; + if payload.root_event_id != root_event_id { + return Err(EventEncodeError::InvalidField("root_event_id")); + } + if payload.prev_event_id != prev_event_id { + return Err(EventEncodeError::InvalidField("prev_event_id")); + } + active_trade_envelope_event_build( + &payload.seller_pubkey, + RadrootsActiveTradeMessageType::TradeOrderRevisionDecision, + &payload.listing_addr, + &payload.order_id, + None, + Some(root_event_id), + Some(prev_event_id), + payload, + ) +} + +#[cfg(feature = "serde_json")] pub fn active_trade_fulfillment_update_event_build( root_event_id: &str, prev_event_id: &str, diff --git a/crates/events_codec/src/trade/mod.rs b/crates/events_codec/src/trade/mod.rs @@ -9,15 +9,16 @@ pub use decode::{ active_trade_buyer_receipt_from_event, active_trade_envelope_from_event, active_trade_event_context_from_tags, active_trade_fulfillment_update_from_event, active_trade_order_cancel_from_event, active_trade_order_decision_from_event, - active_trade_order_request_from_event, active_trade_order_revision_proposal_from_event, - trade_envelope_from_event, trade_event_context_from_tags, + active_trade_order_request_from_event, active_trade_order_revision_decision_from_event, + active_trade_order_revision_proposal_from_event, trade_envelope_from_event, + trade_event_context_from_tags, }; #[cfg(feature = "serde_json")] pub use encode::{ active_trade_buyer_receipt_event_build, active_trade_fulfillment_update_event_build, active_trade_order_cancel_event_build, active_trade_order_decision_event_build, - active_trade_order_request_event_build, active_trade_order_revision_proposal_event_build, - trade_envelope_event_build, + active_trade_order_request_event_build, active_trade_order_revision_decision_event_build, + active_trade_order_revision_proposal_event_build, trade_envelope_event_build, }; pub use tags::{ TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag, diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -12,7 +12,8 @@ use radroots_events::trade::{ RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, + RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, }; use radroots_events_codec::trade::RadrootsTradeListingAddress as TradeListingAddress; use thiserror::Error; @@ -57,6 +58,26 @@ pub struct RadrootsActiveOrderDecisionRecord { } #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderRevisionProposalRecord { + pub event_id: String, + pub author_pubkey: String, + pub counterparty_pubkey: String, + pub root_event_id: String, + pub prev_event_id: String, + pub payload: RadrootsTradeOrderRevisionProposed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderRevisionDecisionRecord { + pub event_id: String, + pub author_pubkey: String, + pub counterparty_pubkey: String, + pub root_event_id: String, + pub prev_event_id: String, + pub payload: RadrootsTradeOrderRevisionDecisionEvent, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsActiveOrderFulfillmentRecord { pub event_id: String, pub author_pubkey: String, @@ -121,6 +142,29 @@ pub enum RadrootsActiveOrderReducerIssue { DecisionInventoryCommitmentMismatch { event_id: String }, DecisionMissingReason { event_id: String }, ConflictingDecisions { event_ids: Vec<String> }, + RevisionProposalWithoutAcceptedDecision { event_id: String }, + RevisionProposalPayloadInvalid { event_id: String }, + RevisionProposalOrderIdMismatch { event_id: String }, + RevisionProposalAuthorMismatch { event_id: String }, + RevisionProposalCounterpartyMismatch { event_id: String }, + RevisionProposalBuyerMismatch { event_id: String }, + RevisionProposalSellerMismatch { event_id: String }, + RevisionProposalListingAddressInvalid { event_id: String }, + RevisionProposalListingMismatch { event_id: String }, + RevisionProposalRootMismatch { event_id: String }, + RevisionProposalPreviousMismatch { event_id: String }, + RevisionDecisionWithoutProposal { event_id: String }, + RevisionDecisionPayloadInvalid { event_id: String }, + RevisionDecisionOrderIdMismatch { event_id: String }, + RevisionDecisionAuthorMismatch { event_id: String }, + RevisionDecisionCounterpartyMismatch { event_id: String }, + RevisionDecisionBuyerMismatch { event_id: String }, + RevisionDecisionSellerMismatch { event_id: String }, + RevisionDecisionListingAddressInvalid { event_id: String }, + RevisionDecisionListingMismatch { event_id: String }, + RevisionDecisionRootMismatch { event_id: String }, + RevisionDecisionPreviousMismatch { event_id: String }, + RevisionDecisionRevisionIdMismatch { event_id: String }, FulfillmentWithoutAcceptedDecision { event_id: String }, FulfillmentPayloadInvalid { event_id: String }, FulfillmentOrderIdMismatch { event_id: String }, @@ -178,6 +222,7 @@ pub struct RadrootsActiveOrderProjection { pub settlement_pending: bool, pub settlement_reason: Option<String>, pub economics: Option<RadrootsTradeOrderEconomics>, + pub agreement_event_id: Option<String>, pub listing_addr: Option<String>, pub buyer_pubkey: Option<String>, pub seller_pubkey: Option<String>, @@ -241,28 +286,36 @@ pub struct RadrootsListingInventoryAccountingProjection { pub issues: Vec<RadrootsListingInventoryAccountingIssue>, } -pub fn reduce_active_order_events<I, J, K, L, M>( +pub fn reduce_active_order_events<I, J, K, L, M, N, O>( order_id: &str, requests: I, decisions: J, - fulfillments: K, - cancellations: L, - receipts: M, + revision_proposals: K, + revision_decisions: L, + fulfillments: M, + cancellations: N, + receipts: O, ) -> RadrootsActiveOrderProjection where I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, J: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, - K: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, - L: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, - M: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, + K: IntoIterator<Item = RadrootsActiveOrderRevisionProposalRecord>, + L: IntoIterator<Item = RadrootsActiveOrderRevisionDecisionRecord>, + M: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + N: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, + O: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, { let requests = unique_request_records(requests); let decisions = unique_decision_records(decisions); + let revision_proposals = unique_revision_proposal_records(revision_proposals); + let revision_decisions = unique_revision_decision_records(revision_decisions); let fulfillments = unique_fulfillment_records(fulfillments); let cancellations = unique_cancellation_records(cancellations); let receipts = unique_receipt_records(receipts); if requests.is_empty() && decisions.is_empty() + && revision_proposals.is_empty() + && revision_decisions.is_empty() && fulfillments.is_empty() && cancellations.is_empty() && receipts.is_empty() @@ -283,6 +336,7 @@ where settlement_pending: false, settlement_reason: None, economics: None, + agreement_event_id: None, listing_addr: None, buyer_pubkey: None, seller_pubkey: None, @@ -310,6 +364,8 @@ where let Some(request) = valid_requests.first() else { if decisions.is_empty() + && revision_proposals.is_empty() + && revision_decisions.is_empty() && fulfillments.is_empty() && cancellations.is_empty() && receipts.is_empty() @@ -331,6 +387,20 @@ where } } + let mut valid_revision_proposals = Vec::new(); + for proposal in revision_proposals { + if validate_active_revision_proposal_record(request, &proposal, &mut issues) { + valid_revision_proposals.push(proposal); + } + } + + let mut valid_revision_decisions = Vec::new(); + for decision in revision_decisions { + if validate_active_revision_decision_record(request, &decision, &mut issues) { + valid_revision_decisions.push(decision); + } + } + if !issues.is_empty() { return invalid_projection(order_id, Some(request), issues); } @@ -375,6 +445,11 @@ where match valid_decisions.len() { 0 => { + record_revision_proposal_without_accepted_decision( + &valid_revision_proposals, + &mut issues, + ); + record_revision_decision_without_proposal(&valid_revision_decisions, &mut issues); if !fulfillments.is_empty() { record_fulfillment_without_accepted_decision(&fulfillments, &mut issues); } @@ -393,6 +468,8 @@ where order_id, request, &valid_decisions[0], + valid_revision_proposals, + valid_revision_decisions, fulfillments, valid_cancellations, valid_receipts, @@ -412,23 +489,27 @@ where } } -pub fn reduce_listing_inventory_accounting<I, J, K, L, M, N>( +pub fn reduce_listing_inventory_accounting<I, J, K, L, M, N, O, P>( listing_addr: &str, listing_event_id: &str, bins: I, requests: J, decisions: K, - fulfillments: L, - cancellations: M, - receipts: N, + revision_proposals: L, + revision_decisions: M, + fulfillments: N, + cancellations: O, + receipts: P, ) -> RadrootsListingInventoryAccountingProjection where I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, J: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, K: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, - L: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, - M: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, - N: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, + L: IntoIterator<Item = RadrootsActiveOrderRevisionProposalRecord>, + M: IntoIterator<Item = RadrootsActiveOrderRevisionDecisionRecord>, + N: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + O: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, + P: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, { let (mut bins, mut issues) = normalized_listing_inventory_bins(bins); let requests = unique_request_records(requests) @@ -439,6 +520,14 @@ where .into_iter() .filter(|decision| decision.payload.listing_addr.trim() == listing_addr) .collect::<Vec<_>>(); + let revision_proposals = unique_revision_proposal_records(revision_proposals) + .into_iter() + .filter(|proposal| proposal.payload.listing_addr.trim() == listing_addr) + .collect::<Vec<_>>(); + let revision_decisions = unique_revision_decision_records(revision_decisions) + .into_iter() + .filter(|decision| decision.payload.listing_addr.trim() == listing_addr) + .collect::<Vec<_>>(); let fulfillments = unique_fulfillment_records(fulfillments) .into_iter() .filter(|fulfillment| fulfillment.payload.listing_addr.trim() == listing_addr) @@ -454,6 +543,8 @@ where let mut order_ids = listing_order_ids( &requests, &decisions, + &revision_proposals, + &revision_decisions, &fulfillments, &cancellations, &receipts, @@ -478,6 +569,16 @@ where .filter(|fulfillment| fulfillment.payload.order_id == order_id) .cloned() .collect::<Vec<_>>(); + let order_revision_proposals = revision_proposals + .iter() + .filter(|proposal| proposal.payload.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); + let order_revision_decisions = revision_decisions + .iter() + .filter(|decision| decision.payload.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); let order_cancellations = cancellations .iter() .filter(|cancellation| cancellation.payload.order_id == order_id) @@ -492,6 +593,8 @@ where &order_id, order_requests.clone(), order_decisions.clone(), + order_revision_proposals.clone(), + order_revision_decisions.clone(), order_fulfillments.clone(), order_cancellations.clone(), order_receipts.clone(), @@ -505,15 +608,14 @@ where { continue; } - 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) + if let Some(agreement_event_id) = projection.agreement_event_id.as_deref() + && let Some(economics) = projection.economics.as_ref() { - add_accepted_inventory_reservations( + add_accepted_inventory_reservations_from_economics( &mut bins, &order_id, - decision, + agreement_event_id, + economics, &mut issues, ); } @@ -534,6 +636,16 @@ where .map(|decision| decision.event_id.clone()), ); event_ids.extend( + order_revision_proposals + .iter() + .map(|proposal| proposal.event_id.clone()), + ); + event_ids.extend( + order_revision_decisions + .iter() + .map(|decision| decision.event_id.clone()), + ); + event_ids.extend( order_fulfillments .iter() .map(|fulfillment| fulfillment.event_id.clone()), @@ -743,6 +855,50 @@ where unique } +fn unique_revision_proposal_records<I>( + revision_proposals: I, +) -> Vec<RadrootsActiveOrderRevisionProposalRecord> +where + I: IntoIterator<Item = RadrootsActiveOrderRevisionProposalRecord>, +{ + let mut unique = Vec::new(); + let mut records = revision_proposals.into_iter().collect::<Vec<_>>(); + records.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + for proposal in records { + if unique + .iter() + .all(|existing: &RadrootsActiveOrderRevisionProposalRecord| { + existing.event_id != proposal.event_id + }) + { + unique.push(proposal); + } + } + unique +} + +fn unique_revision_decision_records<I>( + revision_decisions: I, +) -> Vec<RadrootsActiveOrderRevisionDecisionRecord> +where + I: IntoIterator<Item = RadrootsActiveOrderRevisionDecisionRecord>, +{ + let mut unique = Vec::new(); + let mut records = revision_decisions.into_iter().collect::<Vec<_>>(); + records.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + for decision in records { + if unique + .iter() + .all(|existing: &RadrootsActiveOrderRevisionDecisionRecord| { + existing.event_id != decision.event_id + }) + { + unique.push(decision); + } + } + unique +} + fn unique_fulfillment_records<I>(fulfillments: I) -> Vec<RadrootsActiveOrderFulfillmentRecord> where I: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, @@ -854,6 +1010,8 @@ where fn listing_order_ids( requests: &[RadrootsActiveOrderRequestRecord], decisions: &[RadrootsActiveOrderDecisionRecord], + revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord], + revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord], fulfillments: &[RadrootsActiveOrderFulfillmentRecord], cancellations: &[RadrootsActiveOrderCancellationRecord], receipts: &[RadrootsActiveOrderReceiptRecord], @@ -870,6 +1028,16 @@ fn listing_order_ids( .map(|decision| decision.payload.order_id.clone()), ); order_ids.extend( + revision_proposals + .iter() + .map(|proposal| proposal.payload.order_id.clone()), + ); + order_ids.extend( + revision_decisions + .iter() + .map(|decision| decision.payload.order_id.clone()), + ); + order_ids.extend( fulfillments .iter() .map(|fulfillment| fulfillment.payload.order_id.clone()), @@ -888,41 +1056,34 @@ fn listing_order_ids( order_ids } -fn add_accepted_inventory_reservations( +fn add_accepted_inventory_reservations_from_economics( bins: &mut [RadrootsListingInventoryBinAccounting], order_id: &str, - decision: &RadrootsActiveOrderDecisionRecord, + agreement_event_id: &str, + economics: &RadrootsTradeOrderEconomics, 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) { - add_inventory_reservation(bin, order_id, decision, commitment.bin_count, issues); + for item in &economics.items { + if let Some(bin) = bins.iter_mut().find(|bin| bin.bin_id == item.bin_id) { + add_inventory_reservation_event( + bin, + order_id, + agreement_event_id, + u64::from(item.bin_count), + issues, + ); } else { issues.push( RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { - bin_id: commitment.bin_id, - event_ids: vec![decision.event_id.clone()], + bin_id: item.bin_id.clone(), + event_ids: vec![agreement_event_id.to_string()], }, ); } } } +#[cfg(test)] fn add_inventory_reservation( bin: &mut RadrootsListingInventoryBinAccounting, order_id: &str, @@ -930,19 +1091,29 @@ fn add_inventory_reservation( bin_count: u64, issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, ) { + add_inventory_reservation_event(bin, order_id, &decision.event_id, bin_count, issues); +} + +fn add_inventory_reservation_event( + bin: &mut RadrootsListingInventoryBinAccounting, + order_id: &str, + event_id: &str, + bin_count: u64, + issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, +) { if let Some(next_count) = bin.accepted_reserved_count.checked_add(bin_count) { bin.accepted_reserved_count = next_count; bin.accepted_orders .push(RadrootsListingInventoryOrderReservation { order_id: order_id.to_string(), - decision_event_id: decision.event_id.clone(), + decision_event_id: event_id.to_string(), bin_count, }); } else { issues.push( RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: bin.bin_id.clone(), - event_ids: vec![decision.event_id.clone()], + event_ids: vec![event_id.to_string()], }, ); } @@ -1008,6 +1179,31 @@ fn projection_issue_event_ids(issues: &[RadrootsActiveOrderReducerIssue]) -> Vec | RadrootsActiveOrderReducerIssue::DecisionMissingInventoryCommitments { event_id } | RadrootsActiveOrderReducerIssue::DecisionInventoryCommitmentMismatch { event_id } | RadrootsActiveOrderReducerIssue::DecisionMissingReason { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalWithoutAcceptedDecision { + event_id, + } + | RadrootsActiveOrderReducerIssue::RevisionProposalPayloadInvalid { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalOrderIdMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalAuthorMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalCounterpartyMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalBuyerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalSellerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalListingAddressInvalid { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalListingMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalRootMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionProposalPreviousMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionPayloadInvalid { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionOrderIdMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionAuthorMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionCounterpartyMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionBuyerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionSellerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionListingAddressInvalid { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionListingMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionRootMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionPreviousMismatch { event_id } + | RadrootsActiveOrderReducerIssue::RevisionDecisionRevisionIdMismatch { event_id } | RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision { event_id } | RadrootsActiveOrderReducerIssue::FulfillmentPayloadInvalid { event_id } | RadrootsActiveOrderReducerIssue::FulfillmentOrderIdMismatch { event_id } @@ -1239,6 +1435,206 @@ fn validate_active_decision_record( valid } +fn validate_active_revision_proposal_record( + request: &RadrootsActiveOrderRequestRecord, + proposal: &RadrootsActiveOrderRevisionProposalRecord, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + let mut valid = true; + if proposal.payload.validate().is_err() { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalPayloadInvalid { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + if proposal.payload.order_id != request.payload.order_id { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalOrderIdMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + if proposal.author_pubkey != proposal.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalAuthorMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + if proposal.counterparty_pubkey != request.payload.buyer_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalCounterpartyMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + if proposal.payload.buyer_pubkey != request.payload.buyer_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalBuyerMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + if proposal.payload.seller_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalSellerMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + match parse_public_listing_addr(&proposal.payload.listing_addr) { + Ok(listing_addr) => { + if proposal.payload.listing_addr != request.payload.listing_addr + || listing_addr.seller_pubkey != proposal.payload.seller_pubkey + { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalListingMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + } + Err(_) => { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalListingAddressInvalid { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + } + if proposal.root_event_id != request.event_id + || proposal.payload.root_event_id != request.event_id + { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalRootMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + if proposal.prev_event_id.trim().is_empty() + || proposal.prev_event_id == proposal.event_id + || proposal.payload.prev_event_id != proposal.prev_event_id + { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalPreviousMismatch { + event_id: proposal.event_id.clone(), + }, + ); + valid = false; + } + valid +} + +fn validate_active_revision_decision_record( + request: &RadrootsActiveOrderRequestRecord, + decision: &RadrootsActiveOrderRevisionDecisionRecord, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + let mut valid = true; + if decision.payload.validate().is_err() { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionPayloadInvalid { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + if decision.payload.order_id != request.payload.order_id { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionOrderIdMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + if decision.author_pubkey != decision.payload.buyer_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionAuthorMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + if decision.counterparty_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionCounterpartyMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + if decision.payload.buyer_pubkey != request.payload.buyer_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionBuyerMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + if decision.payload.seller_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionSellerMismatch { + 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::RevisionDecisionListingMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + } + Err(_) => { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionListingAddressInvalid { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + } + if decision.root_event_id != request.event_id + || decision.payload.root_event_id != request.event_id + { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionRootMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + if decision.prev_event_id.trim().is_empty() + || decision.prev_event_id == decision.event_id + || decision.payload.prev_event_id != decision.prev_event_id + { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionPreviousMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } + valid +} + fn validate_active_fulfillment_record( request: &RadrootsActiveOrderRequestRecord, fulfillment: &RadrootsActiveOrderFulfillmentRecord, @@ -1552,27 +1948,53 @@ fn record_fulfillment_without_accepted_decision( } } -fn record_cancellation_without_cancellable_order( - cancellations: &[RadrootsActiveOrderCancellationRecord], +fn record_revision_proposal_without_accepted_decision( + revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord], issues: &mut Vec<RadrootsActiveOrderReducerIssue>, ) { - for cancellation in cancellations { + for proposal in revision_proposals { issues.push( - RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { - event_id: cancellation.event_id.clone(), + RadrootsActiveOrderReducerIssue::RevisionProposalWithoutAcceptedDecision { + event_id: proposal.event_id.clone(), }, ); } } -fn record_receipt_without_eligible_fulfillment( - receipts: &[RadrootsActiveOrderReceiptRecord], +fn record_revision_decision_without_proposal( + revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord], issues: &mut Vec<RadrootsActiveOrderReducerIssue>, ) { - for receipt in receipts { + for decision in revision_decisions { issues.push( - RadrootsActiveOrderReducerIssue::ReceiptWithoutEligibleFulfillment { - event_id: receipt.event_id.clone(), + RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal { + event_id: decision.event_id.clone(), + }, + ); + } +} + +fn record_cancellation_without_cancellable_order( + cancellations: &[RadrootsActiveOrderCancellationRecord], + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) { + for cancellation in cancellations { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { + event_id: cancellation.event_id.clone(), + }, + ); + } +} + +fn record_receipt_without_eligible_fulfillment( + receipts: &[RadrootsActiveOrderReceiptRecord], + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) { + for receipt in receipts { + issues.push( + RadrootsActiveOrderReducerIssue::ReceiptWithoutEligibleFulfillment { + event_id: receipt.event_id.clone(), }, ); } @@ -1610,8 +2032,132 @@ fn validated_fulfillment_records( valid_fulfillments } -fn latest_fulfillment_record( +struct RadrootsActiveRevisionState { + agreement_event_id: String, + lifecycle_parent_event_id: String, + economics: RadrootsTradeOrderEconomics, + pending_revision_event_id: Option<String>, +} + +fn active_revision_state( + request: &RadrootsActiveOrderRequestRecord, decision: &RadrootsActiveOrderDecisionRecord, + revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord], + revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord], + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> Option<RadrootsActiveRevisionState> { + let mut state = RadrootsActiveRevisionState { + agreement_event_id: decision.event_id.clone(), + lifecycle_parent_event_id: decision.event_id.clone(), + economics: request.payload.economics.clone(), + pending_revision_event_id: None, + }; + let mut used_proposal_event_ids = Vec::new(); + let mut used_decision_event_ids = Vec::new(); + + loop { + let matching_proposals = revision_proposals + .iter() + .filter(|proposal| { + proposal.prev_event_id == state.lifecycle_parent_event_id + && !used_proposal_event_ids.contains(&proposal.event_id) + }) + .cloned() + .collect::<Vec<_>>(); + let proposal = match single_lifecycle_child(&matching_proposals, |record| &record.event_id) + { + Ok(Some(proposal)) => proposal, + Ok(None) => break, + Err(issue) => { + issues.push(issue); + return None; + } + }; + used_proposal_event_ids.push(proposal.event_id.clone()); + let matching_decisions = revision_decisions + .iter() + .filter(|decision| { + decision.prev_event_id == proposal.event_id + && !used_decision_event_ids.contains(&decision.event_id) + }) + .cloned() + .collect::<Vec<_>>(); + let revision_decision = + match single_lifecycle_child(&matching_decisions, |record| &record.event_id) { + Ok(Some(decision)) => decision, + Ok(None) => { + state.pending_revision_event_id = Some(proposal.event_id.clone()); + state.lifecycle_parent_event_id = proposal.event_id; + break; + } + Err(issue) => { + issues.push(issue); + return None; + } + }; + if revision_decision.payload.revision_id != proposal.payload.revision_id { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionRevisionIdMismatch { + event_id: revision_decision.event_id.clone(), + }, + ); + return None; + } + used_decision_event_ids.push(revision_decision.event_id.clone()); + match revision_decision.payload.decision { + RadrootsTradeOrderRevisionDecision::Accepted => { + state.agreement_event_id = revision_decision.event_id.clone(); + state.economics = proposal.payload.economics; + } + RadrootsTradeOrderRevisionDecision::Declined { .. } => {} + } + state.lifecycle_parent_event_id = revision_decision.event_id; + } + + for proposal in revision_proposals { + if !used_proposal_event_ids.contains(&proposal.event_id) { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionProposalPreviousMismatch { + event_id: proposal.event_id.clone(), + }, + ); + } + } + for decision in revision_decisions { + if used_decision_event_ids.contains(&decision.event_id) { + continue; + } + if let Some(proposal) = revision_proposals + .iter() + .find(|proposal| proposal.event_id == decision.prev_event_id) + { + if proposal.payload.revision_id != decision.payload.revision_id { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionRevisionIdMismatch { + event_id: decision.event_id.clone(), + }, + ); + } else { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionPreviousMismatch { + event_id: decision.event_id.clone(), + }, + ); + } + } else { + issues.push( + RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal { + event_id: decision.event_id.clone(), + }, + ); + } + } + + if issues.is_empty() { Some(state) } else { None } +} + +fn latest_fulfillment_record( + parent_event_id: &str, valid_fulfillments: &[RadrootsActiveOrderFulfillmentRecord], issues: &mut Vec<RadrootsActiveOrderReducerIssue>, ) -> Option<RadrootsActiveOrderFulfillmentRecord> { @@ -1619,7 +2165,7 @@ fn latest_fulfillment_record( return None; } let mut used_event_ids = Vec::new(); - let mut previous_event_id = decision.event_id.clone(); + let mut previous_event_id = parent_event_id.to_string(); let mut previous_status = RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled; let mut latest = None; @@ -1695,6 +2241,7 @@ fn requested_projection( settlement_pending: false, settlement_reason: None, economics: Some(request.payload.economics.clone()), + agreement_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()), @@ -1727,7 +2274,14 @@ fn requested_cancellation_projection( .filter(|cancellation| cancellation.prev_event_id == request.event_id) .collect::<Vec<_>>(); match single_lifecycle_child(&matching, |record| &record.event_id) { - Ok(Some(cancellation)) => cancelled_projection(order_id, request, None, cancellation), + Ok(Some(cancellation)) => cancelled_projection( + order_id, + request, + None, + None, + request.payload.economics.clone(), + cancellation, + ), Ok(None) => requested_projection(order_id, request), Err(issue) => invalid_projection(order_id, Some(request), vec![issue]), } @@ -1737,6 +2291,8 @@ fn decided_projection( order_id: &str, request: &RadrootsActiveOrderRequestRecord, decision: &RadrootsActiveOrderDecisionRecord, + revision_proposals: Vec<RadrootsActiveOrderRevisionProposalRecord>, + revision_decisions: Vec<RadrootsActiveOrderRevisionDecisionRecord>, fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>, cancellations: Vec<RadrootsActiveOrderCancellationRecord>, receipts: Vec<RadrootsActiveOrderReceiptRecord>, @@ -1746,119 +2302,176 @@ fn decided_projection( RadrootsTradeOrderDecision::Declined { .. } => RadrootsActiveOrderStatus::Declined, }; let mut issues = Vec::new(); - let (fulfillment_event_id, fulfillment_status, last_event_id) = match status { - RadrootsActiveOrderStatus::Accepted => { - let fulfillment_records = - validated_fulfillment_records(request, fulfillments, &mut issues); - let latest = latest_fulfillment_record(decision, &fulfillment_records, &mut issues); - if !issues.is_empty() { - return invalid_projection(order_id, Some(request), issues); - } - let decision_cancellations = cancellations - .iter() - .cloned() - .filter(|cancellation| cancellation.prev_event_id == decision.event_id) - .collect::<Vec<_>>(); - for cancellation in cancellations - .iter() - .filter(|cancellation| cancellation.prev_event_id != decision.event_id) - { - issues.push( - RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { - event_id: cancellation.event_id.clone(), - }, + let (fulfillment_event_id, fulfillment_status, last_event_id, agreement_event_id, economics) = + match status { + RadrootsActiveOrderStatus::Accepted => { + let Some(revision_state) = active_revision_state( + request, + decision, + &revision_proposals, + &revision_decisions, + &mut issues, + ) else { + return invalid_projection(order_id, Some(request), issues); + }; + if let Some(pending_revision_event_id) = + revision_state.pending_revision_event_id.as_ref() + && (!fulfillments.is_empty() + || !cancellations.is_empty() + || !receipts.is_empty()) + { + let mut event_ids = vec![pending_revision_event_id.clone()]; + event_ids.extend( + fulfillments + .iter() + .map(|fulfillment| fulfillment.event_id.clone()), + ); + event_ids.extend( + cancellations + .iter() + .map(|cancellation| cancellation.event_id.clone()), + ); + event_ids.extend(receipts.iter().map(|receipt| receipt.event_id.clone())); + sort_and_dedup_strings(&mut event_ids); + return invalid_projection( + order_id, + Some(request), + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }], + ); + } + let fulfillment_records = + validated_fulfillment_records(request, fulfillments, &mut issues); + let latest = latest_fulfillment_record( + &revision_state.lifecycle_parent_event_id, + &fulfillment_records, + &mut issues, ); - } - if !issues.is_empty() { - return invalid_projection(order_id, Some(request), issues); - } - if let Some(first_fulfillment) = fulfillment_records - .iter() - .find(|fulfillment| fulfillment.prev_event_id == decision.event_id) - && !decision_cancellations.is_empty() - { - let mut event_ids = decision_cancellations + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + let decision_cancellations = cancellations .iter() - .map(|cancellation| cancellation.event_id.clone()) + .cloned() + .filter(|cancellation| { + cancellation.prev_event_id == revision_state.lifecycle_parent_event_id + }) .collect::<Vec<_>>(); - event_ids.push(first_fulfillment.event_id.clone()); - sort_and_dedup_strings(&mut event_ids); - return invalid_projection( - order_id, - Some(request), - vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }], - ); - } - if latest.is_some() { - for cancellation in decision_cancellations { + for cancellation in cancellations.iter().filter(|cancellation| { + cancellation.prev_event_id != revision_state.lifecycle_parent_event_id + }) { issues.push( - RadrootsActiveOrderReducerIssue::CancellationAfterFulfillment { - event_id: cancellation.event_id, + RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { + event_id: cancellation.event_id.clone(), }, ); } if !issues.is_empty() { return invalid_projection(order_id, Some(request), issues); } - } else { - match single_lifecycle_child(&decision_cancellations, |record| &record.event_id) { - Ok(Some(cancellation)) => { - return cancelled_projection( - order_id, - request, - Some(decision.event_id.clone()), - cancellation, + if let Some(first_fulfillment) = fulfillment_records.iter().find(|fulfillment| { + fulfillment.prev_event_id == revision_state.lifecycle_parent_event_id + }) && !decision_cancellations.is_empty() + { + let mut event_ids = decision_cancellations + .iter() + .map(|cancellation| cancellation.event_id.clone()) + .collect::<Vec<_>>(); + event_ids.push(first_fulfillment.event_id.clone()); + sort_and_dedup_strings(&mut event_ids); + return invalid_projection( + order_id, + Some(request), + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }], + ); + } + if latest.is_some() { + for cancellation in decision_cancellations { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationAfterFulfillment { + event_id: cancellation.event_id, + }, ); } - Ok(None) => {} - Err(issue) => return invalid_projection(order_id, Some(request), vec![issue]), + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + } else { + match single_lifecycle_child(&decision_cancellations, |record| &record.event_id) + { + Ok(Some(cancellation)) => { + return cancelled_projection( + order_id, + request, + Some(decision.event_id.clone()), + Some(revision_state.agreement_event_id.clone()), + revision_state.economics.clone(), + cancellation, + ); + } + Ok(None) => {} + Err(issue) => { + return invalid_projection(order_id, Some(request), vec![issue]); + } + } } + let receipt_result = receipt_projection( + order_id, + request, + decision, + &revision_state.agreement_event_id, + &revision_state.economics, + latest.as_ref(), + &fulfillment_records, + receipts, + &mut issues, + ); + if let Some(projection) = receipt_result { + return projection; + } + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + let (fulfillment_event_id, fulfillment_status, last_event_id) = match latest { + Some(fulfillment) => ( + Some(fulfillment.event_id.clone()), + Some(fulfillment.payload.status), + Some(fulfillment.event_id), + ), + None => ( + None, + Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled), + Some(revision_state.lifecycle_parent_event_id.clone()), + ), + }; + ( + fulfillment_event_id, + fulfillment_status, + last_event_id, + Some(revision_state.agreement_event_id), + Some(revision_state.economics), + ) } - let receipt_result = receipt_projection( - order_id, - request, - decision, - latest.as_ref(), - &fulfillment_records, - receipts, - &mut issues, - ); - if let Some(projection) = receipt_result { - return projection; - } - if !issues.is_empty() { - return invalid_projection(order_id, Some(request), issues); - } - match latest { - Some(fulfillment) => ( - Some(fulfillment.event_id.clone()), - Some(fulfillment.payload.status), - Some(fulfillment.event_id), - ), - None => ( - None, - Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled), - Some(decision.event_id.clone()), - ), - } - } - RadrootsActiveOrderStatus::Declined => { - if fulfillments.is_empty() && cancellations.is_empty() && receipts.is_empty() { - (None, None, Some(decision.event_id.clone())) - } else { - record_fulfillment_without_accepted_decision(&fulfillments, &mut issues); - record_cancellation_without_cancellable_order(&cancellations, &mut issues); - record_receipt_without_eligible_fulfillment(&receipts, &mut issues); - return invalid_projection(order_id, Some(request), issues); + RadrootsActiveOrderStatus::Declined => { + record_revision_proposal_without_accepted_decision( + &revision_proposals, + &mut issues, + ); + record_revision_decision_without_proposal(&revision_decisions, &mut issues); + if fulfillments.is_empty() + && cancellations.is_empty() + && receipts.is_empty() + && issues.is_empty() + { + (None, None, Some(decision.event_id.clone()), None, None) + } else { + record_fulfillment_without_accepted_decision(&fulfillments, &mut issues); + record_cancellation_without_cancellable_order(&cancellations, &mut issues); + record_receipt_without_eligible_fulfillment(&receipts, &mut issues); + return invalid_projection(order_id, Some(request), issues); + } } - } - _ => (None, None, Some(decision.event_id.clone())), - }; - let economics = if status == RadrootsActiveOrderStatus::Accepted { - Some(request.payload.economics.clone()) - } else { - None - }; + _ => (None, None, Some(decision.event_id.clone()), None, None), + }; RadrootsActiveOrderProjection { order_id: order_id.to_string(), status, @@ -1875,6 +2488,7 @@ fn decided_projection( settlement_pending: false, settlement_reason: None, economics, + agreement_event_id, listing_addr: Some(request.payload.listing_addr.clone()), buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), seller_pubkey: Some(request.payload.seller_pubkey.clone()), @@ -1887,6 +2501,8 @@ fn receipt_projection( order_id: &str, request: &RadrootsActiveOrderRequestRecord, decision: &RadrootsActiveOrderDecisionRecord, + agreement_event_id: &str, + economics: &RadrootsTradeOrderEconomics, latest_fulfillment: Option<&RadrootsActiveOrderFulfillmentRecord>, fulfillments: &[RadrootsActiveOrderFulfillmentRecord], receipts: Vec<RadrootsActiveOrderReceiptRecord>, @@ -1949,6 +2565,8 @@ fn receipt_projection( order_id, request, decision, + agreement_event_id, + economics, fulfillment, receipt, )), @@ -1971,6 +2589,8 @@ fn cancelled_projection( order_id: &str, request: &RadrootsActiveOrderRequestRecord, decision_event_id: Option<String>, + agreement_event_id: Option<String>, + economics: RadrootsTradeOrderEconomics, cancellation: RadrootsActiveOrderCancellationRecord, ) -> RadrootsActiveOrderProjection { RadrootsActiveOrderProjection { @@ -1988,7 +2608,8 @@ fn cancelled_projection( lifecycle_terminal: true, settlement_pending: true, settlement_reason: Some(cancellation.payload.reason), - economics: Some(request.payload.economics.clone()), + economics: Some(economics), + agreement_event_id, listing_addr: Some(request.payload.listing_addr.clone()), buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), seller_pubkey: Some(request.payload.seller_pubkey.clone()), @@ -2001,6 +2622,8 @@ fn receipt_terminal_projection( order_id: &str, request: &RadrootsActiveOrderRequestRecord, decision: &RadrootsActiveOrderDecisionRecord, + agreement_event_id: &str, + economics: &RadrootsTradeOrderEconomics, fulfillment: &RadrootsActiveOrderFulfillmentRecord, receipt: RadrootsActiveOrderReceiptRecord, ) -> RadrootsActiveOrderProjection { @@ -2024,7 +2647,8 @@ fn receipt_terminal_projection( lifecycle_terminal: true, settlement_pending: !receipt.payload.received, settlement_reason: receipt.payload.issue, - economics: Some(request.payload.economics.clone()), + economics: Some(economics.clone()), + agreement_event_id: Some(agreement_event_id.to_string()), listing_addr: Some(request.payload.listing_addr.clone()), buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), seller_pubkey: Some(request.payload.seller_pubkey.clone()), @@ -2060,6 +2684,7 @@ fn invalid_projection( settlement_pending: false, settlement_reason: None, economics, + agreement_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()), @@ -2222,19 +2847,24 @@ mod tests { RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, RadrootsTradePricingBasis, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, + RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, + RadrootsTradePricingBasis, }; use super::{ RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, - RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderReceiptRecord, - RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, - RadrootsActiveOrderStatus, RadrootsListingInventoryAccountingIssue, + RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderProjection, + RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderReducerIssue, + RadrootsActiveOrderRequestRecord, RadrootsActiveOrderRevisionDecisionRecord, + RadrootsActiveOrderRevisionProposalRecord, RadrootsActiveOrderStatus, + RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection, RadrootsListingInventoryBinAccounting, RadrootsListingInventoryBinAvailability, RadrootsListingInventoryOrderReservation, RadrootsTradeOrderCanonicalizationError, add_inventory_reservation, canonicalize_active_order_decision_for_signer, canonicalize_active_order_request_for_signer, canonicalize_order_request_for_signer, - reduce_active_order_events, reduce_listing_inventory_accounting, + reduce_active_order_events as reduce_active_order_events_with_revisions, + reduce_listing_inventory_accounting as reduce_listing_inventory_accounting_with_revisions, }; const SELLER: &str = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -2493,6 +3123,122 @@ mod tests { } } + fn revision_proposal_record( + event_id: &str, + prev_event_id: &str, + revision_id: &str, + bin_count: u32, + ) -> RadrootsActiveOrderRevisionProposalRecord { + let subtotal = + (RadrootsCoreDecimal::from(5u32) * RadrootsCoreDecimal::from(bin_count)).to_string(); + RadrootsActiveOrderRevisionProposalRecord { + event_id: event_id.to_string(), + author_pubkey: SELLER.to_string(), + counterparty_pubkey: BUYER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: prev_event_id.to_string(), + payload: RadrootsTradeOrderRevisionProposed { + revision_id: revision_id.to_string(), + order_id: "order-1".to_string(), + listing_addr: listing_addr(), + buyer_pubkey: BUYER.to_string(), + seller_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: prev_event_id.to_string(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_string(), + bin_count, + }], + economics: request_economics("bin-1", bin_count, &subtotal), + reason: "field yield changed".to_string(), + }, + } + } + + fn revision_decision_record( + event_id: &str, + prev_event_id: &str, + revision_id: &str, + decision: RadrootsTradeOrderRevisionDecision, + ) -> RadrootsActiveOrderRevisionDecisionRecord { + RadrootsActiveOrderRevisionDecisionRecord { + event_id: event_id.to_string(), + author_pubkey: BUYER.to_string(), + counterparty_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: prev_event_id.to_string(), + payload: RadrootsTradeOrderRevisionDecisionEvent { + revision_id: revision_id.to_string(), + order_id: "order-1".to_string(), + listing_addr: listing_addr(), + buyer_pubkey: BUYER.to_string(), + seller_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: prev_event_id.to_string(), + decision, + }, + } + } + + fn reduce_active_order_events<I, J, K, L, M>( + order_id: &str, + requests: I, + decisions: J, + fulfillments: K, + cancellations: L, + receipts: M, + ) -> RadrootsActiveOrderProjection + where + I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, + J: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, + K: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + L: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, + M: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, + { + reduce_active_order_events_with_revisions( + order_id, + requests, + decisions, + Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(), + Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(), + fulfillments, + cancellations, + receipts, + ) + } + + fn reduce_listing_inventory_accounting<I, J, K, L, M, N>( + listing_addr: &str, + listing_event_id: &str, + bins: I, + requests: J, + decisions: K, + fulfillments: L, + cancellations: M, + receipts: N, + ) -> RadrootsListingInventoryAccountingProjection + where + I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, + J: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, + K: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, + L: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + M: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, + N: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, + { + reduce_listing_inventory_accounting_with_revisions( + listing_addr, + listing_event_id, + bins, + requests, + decisions, + Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(), + Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(), + fulfillments, + cancellations, + receipts, + ) + } + #[test] fn canonicalize_order_request_sets_missing_pubkeys() { let order = canonicalize_order_request_for_signer(base_order("", ""), SELLER) @@ -2666,6 +3412,148 @@ mod tests { } #[test] + fn reduce_active_order_events_applies_accepted_revision_agreement() { + let projection = reduce_active_order_events_with_revisions( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [revision_proposal_record( + "revision-proposal-1", + "decision-1", + "revision-1", + 1, + )], + [revision_decision_record( + "revision-decision-1", + "revision-proposal-1", + "revision-1", + RadrootsTradeOrderRevisionDecision::Accepted, + )], + Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); + assert_eq!( + projection.agreement_event_id.as_deref(), + Some("revision-decision-1") + ); + assert_eq!( + projection.last_event_id.as_deref(), + Some("revision-decision-1") + ); + assert_eq!( + projection.economics, + Some(request_economics("bin-1", 1, "5")) + ); + assert!(projection.issues.is_empty()); + } + + #[test] + fn reduce_active_order_events_preserves_agreement_after_declined_revision() { + let projection = reduce_active_order_events_with_revisions( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [revision_proposal_record( + "revision-proposal-1", + "decision-1", + "revision-1", + 1, + )], + [revision_decision_record( + "revision-decision-1", + "revision-proposal-1", + "revision-1", + RadrootsTradeOrderRevisionDecision::Declined { + reason: "keep original order".to_string(), + }, + )], + Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); + assert_eq!(projection.agreement_event_id.as_deref(), Some("decision-1")); + assert_eq!( + projection.last_event_id.as_deref(), + Some("revision-decision-1") + ); + assert_eq!( + projection.economics, + Some(request_economics("bin-1", 2, "10")) + ); + assert!(projection.issues.is_empty()); + } + + #[test] + fn reduce_active_order_events_rejects_wrong_actor_revision_decision() { + let mut decision = revision_decision_record( + "revision-decision-1", + "revision-proposal-1", + "revision-1", + RadrootsTradeOrderRevisionDecision::Accepted, + ); + decision.author_pubkey = SELLER.to_string(); + + let projection = reduce_active_order_events_with_revisions( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [revision_proposal_record( + "revision-proposal-1", + "decision-1", + "revision-1", + 1, + )], + [decision], + Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::RevisionDecisionAuthorMismatch { event_id } + if event_id == "revision-decision-1" + ))); + } + + #[test] + fn reduce_active_order_events_rejects_stale_revision_decision() { + let projection = reduce_active_order_events_with_revisions( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [revision_proposal_record( + "revision-proposal-1", + "decision-1", + "revision-1", + 1, + )], + [revision_decision_record( + "revision-decision-1", + "unknown-proposal", + "revision-1", + RadrootsTradeOrderRevisionDecision::Accepted, + )], + Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal { event_id } + if event_id == "revision-decision-1" + ))); + } + + #[test] fn reduce_active_order_events_rejects_invalid_request_economics() { let mut request = request_record(); request.payload.economics.total = usd("12"); @@ -3136,6 +4024,44 @@ mod tests { } #[test] + fn reduce_listing_inventory_accounting_reserves_accepted_revision_inventory() { + let projection = reduce_listing_inventory_accounting_with_revisions( + &listing_addr(), + "listing-event-1", + [inventory_bin(5)], + [request_record()], + [accepted_decision_record("decision-1")], + [revision_proposal_record( + "revision-proposal-1", + "decision-1", + "revision-1", + 1, + )], + [revision_decision_record( + "revision-decision-1", + "revision-proposal-1", + "revision-1", + RadrootsTradeOrderRevisionDecision::Accepted, + )], + Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), + ); + + assert!(projection.issues.is_empty()); + assert_eq!(projection.bins[0].accepted_reserved_count, 1); + assert_eq!(projection.bins[0].remaining_count, 4); + assert_eq!( + projection.bins[0].accepted_orders, + vec![RadrootsListingInventoryOrderReservation { + order_id: "order-1".to_string(), + decision_event_id: "revision-decision-1".to_string(), + bin_count: 1, + }] + ); + } + + #[test] fn reduce_listing_inventory_accounting_releases_latest_seller_cancelled_order() { let projection = reduce_listing_inventory_accounting( &listing_addr(),