lib

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

order.rs (72014B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     string::{String, ToString},
      6     vec::Vec,
      7 };
      8 
      9 #[cfg(test)]
     10 use crate::ids::RadrootsOrderQuoteId;
     11 use crate::ids::{
     12     RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId,
     13     RadrootsOrderRevisionId, RadrootsPublicKey,
     14 };
     15 use crate::kinds::*;
     16 pub use crate::order_economics::*;
     17 #[cfg(test)]
     18 use crate::trade_validation::RadrootsTradeValidationListingError;
     19 use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney};
     20 
     21 pub const RADROOTS_COMMERCIAL_LISTING_DOMAIN: &str = "trade:listing";
     22 pub const RADROOTS_ORDER_ENVELOPE_VERSION: u16 = 1;
     23 
     24 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     25 #[derive(Clone, Debug, PartialEq, Eq)]
     26 pub enum RadrootsListingParseError {
     27     InvalidKind(u32),
     28     MissingTag(String),
     29     InvalidTag(String),
     30     InvalidNumber(String),
     31     InvalidUnit,
     32     InvalidCurrency,
     33     InvalidJson(String),
     34     InvalidDiscount(String),
     35 }
     36 
     37 impl core::fmt::Display for RadrootsListingParseError {
     38     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
     39         match self {
     40             Self::InvalidKind(kind) => write!(f, "invalid listing kind: {kind}"),
     41             Self::MissingTag(tag) => write!(f, "missing required tag: {tag}"),
     42             Self::InvalidTag(tag) => write!(f, "invalid tag: {tag}"),
     43             Self::InvalidNumber(field) => write!(f, "invalid number: {field}"),
     44             Self::InvalidUnit => write!(f, "invalid unit"),
     45             Self::InvalidCurrency => write!(f, "invalid currency"),
     46             Self::InvalidJson(field) => write!(f, "invalid json: {field}"),
     47             Self::InvalidDiscount(kind) => write!(f, "invalid discount data for {kind}"),
     48         }
     49     }
     50 }
     51 
     52 #[cfg(feature = "std")]
     53 impl std::error::Error for RadrootsListingParseError {}
     54 
     55 impl RadrootsOrderEconomics {
     56     pub fn canonicalize(&mut self) {
     57         self.items
     58             .sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
     59         self.discounts.sort_by(|left, right| left.id.cmp(&right.id));
     60         self.adjustments
     61             .sort_by(|left, right| left.id.cmp(&right.id));
     62         if let Ok(totals) = self.derived_totals() {
     63             self.subtotal = totals.subtotal;
     64             self.discount_total = totals.discount_total;
     65             self.adjustment_total = totals.adjustment_total;
     66             self.total = totals.total;
     67         }
     68     }
     69 
     70     pub fn canonicalized(&self) -> Self {
     71         let mut economics = self.clone();
     72         economics.canonicalize();
     73         economics
     74     }
     75 
     76     pub fn derived_totals(&self) -> Result<RadrootsOrderEconomicTotals, RadrootsOrderPayloadError> {
     77         if self.items.is_empty() {
     78             return Err(RadrootsOrderPayloadError::MissingEconomicItems);
     79         }
     80 
     81         let mut subtotal = RadrootsCoreMoney::zero(self.currency);
     82         for (index, item) in self.items.iter().enumerate() {
     83             let line_subtotal = validate_economic_item(item, self.currency, index)?;
     84             subtotal = checked_money_add(&subtotal, &line_subtotal, "subtotal")?;
     85         }
     86 
     87         let mut discount_total = RadrootsCoreMoney::zero(self.currency);
     88         for (index, line) in self.discounts.iter().enumerate() {
     89             validate_economic_line(line, self.currency, "discounts", index)?;
     90             if line.kind != RadrootsOrderEconomicLineKind::ListingDiscount {
     91                 return Err(RadrootsOrderPayloadError::InvalidEconomicLineKind {
     92                     field: "discounts",
     93                     index,
     94                 });
     95             }
     96             if line.effect != RadrootsOrderEconomicEffect::Decrease {
     97                 return Err(RadrootsOrderPayloadError::InvalidEconomicLineEffect {
     98                     field: "discounts",
     99                     index,
    100                 });
    101             }
    102             discount_total = checked_money_add(&discount_total, &line.amount, "discount_total")?;
    103         }
    104 
    105         let mut adjustment_total = RadrootsCoreMoney::zero(self.currency);
    106         let mut total = checked_money_sub_non_negative(&subtotal, &discount_total, "total")?;
    107         for (index, line) in self.adjustments.iter().enumerate() {
    108             validate_economic_line(line, self.currency, "adjustments", index)?;
    109             if line.kind == RadrootsOrderEconomicLineKind::ListingDiscount {
    110                 return Err(RadrootsOrderPayloadError::InvalidEconomicLineKind {
    111                     field: "adjustments",
    112                     index,
    113                 });
    114             }
    115             adjustment_total =
    116                 checked_money_add(&adjustment_total, &line.amount, "adjustment_total")?;
    117             total = match line.effect {
    118                 RadrootsOrderEconomicEffect::Increase => {
    119                     checked_money_add(&total, &line.amount, "total")?
    120                 }
    121                 RadrootsOrderEconomicEffect::Decrease => {
    122                     checked_money_sub_non_negative(&total, &line.amount, "total")?
    123                 }
    124             };
    125         }
    126 
    127         Ok(RadrootsOrderEconomicTotals {
    128             subtotal,
    129             discount_total,
    130             adjustment_total,
    131             total,
    132         })
    133     }
    134 
    135     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    136         validate_required_field(&self.quote_id, "quote_id")?;
    137         if self.quote_version == 0 {
    138             return Err(RadrootsOrderPayloadError::InvalidQuoteVersion);
    139         }
    140 
    141         let totals = self.derived_totals()?;
    142         validate_economic_item_order(&self.items)?;
    143         validate_economic_line_order(&self.discounts, "discounts")?;
    144         validate_economic_line_order(&self.adjustments, "adjustments")?;
    145         validate_total_money(&self.subtotal, self.currency, "subtotal")?;
    146         validate_total_money(&self.discount_total, self.currency, "discount_total")?;
    147         validate_total_money(&self.adjustment_total, self.currency, "adjustment_total")?;
    148         validate_total_money(&self.total, self.currency, "total")?;
    149         validate_total_matches(&self.subtotal, &totals.subtotal, "subtotal")?;
    150         validate_total_matches(
    151             &self.discount_total,
    152             &totals.discount_total,
    153             "discount_total",
    154         )?;
    155         validate_total_matches(
    156             &self.adjustment_total,
    157             &totals.adjustment_total,
    158             "adjustment_total",
    159         )?;
    160         validate_total_matches(&self.total, &totals.total, "total")
    161     }
    162 }
    163 
    164 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    165 #[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderRequest")))]
    166 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    167 #[derive(Clone, Debug, PartialEq, Eq)]
    168 pub struct RadrootsOrderRequest {
    169     pub order_id: RadrootsOrderId,
    170     pub listing_addr: RadrootsListingAddress,
    171     pub buyer_pubkey: RadrootsPublicKey,
    172     pub seller_pubkey: RadrootsPublicKey,
    173     pub items: Vec<RadrootsOrderItem>,
    174     pub economics: RadrootsOrderEconomics,
    175 }
    176 
    177 impl RadrootsOrderRequest {
    178     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    179         validate_required_field(&self.order_id, "order_id")?;
    180         validate_required_field(&self.listing_addr, "listing_addr")?;
    181         validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?;
    182         validate_required_field(&self.seller_pubkey, "seller_pubkey")?;
    183         validate_order_items(&self.items)?;
    184         self.economics.validate()?;
    185         validate_order_economics_binding(&self.items, &self.economics)
    186     }
    187 }
    188 
    189 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    190 #[cfg_attr(
    191     feature = "dto-bindgen",
    192     dto(ts(name = "RadrootsOrderRevisionProposed"))
    193 )]
    194 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    195 #[derive(Clone, Debug, PartialEq, Eq)]
    196 pub struct RadrootsOrderRevisionProposal {
    197     pub revision_id: RadrootsOrderRevisionId,
    198     pub order_id: RadrootsOrderId,
    199     pub listing_addr: RadrootsListingAddress,
    200     pub buyer_pubkey: RadrootsPublicKey,
    201     pub seller_pubkey: RadrootsPublicKey,
    202     pub root_event_id: RadrootsEventId,
    203     pub prev_event_id: RadrootsEventId,
    204     pub items: Vec<RadrootsOrderItem>,
    205     pub economics: RadrootsOrderEconomics,
    206     pub reason: String,
    207 }
    208 
    209 impl RadrootsOrderRevisionProposal {
    210     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    211         validate_required_field(&self.revision_id, "revision_id")?;
    212         validate_required_field(&self.order_id, "order_id")?;
    213         validate_required_field(&self.listing_addr, "listing_addr")?;
    214         validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?;
    215         validate_required_field(&self.seller_pubkey, "seller_pubkey")?;
    216         validate_required_field(&self.root_event_id, "root_event_id")?;
    217         validate_required_field(&self.prev_event_id, "prev_event_id")?;
    218         validate_required_field(&self.reason, "reason")?;
    219         validate_order_items(&self.items)?;
    220         self.economics.validate()?;
    221         validate_order_economics_binding(&self.items, &self.economics)
    222     }
    223 }
    224 
    225 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    226 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))]
    227 #[derive(Clone, Debug, PartialEq, Eq)]
    228 pub enum RadrootsOrderRevisionOutcome {
    229     Accepted,
    230     Declined { reason: String },
    231 }
    232 
    233 impl RadrootsOrderRevisionOutcome {
    234     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    235         match self {
    236             Self::Accepted => Ok(()),
    237             Self::Declined { reason } => validate_required_field(reason, "reason"),
    238         }
    239     }
    240 }
    241 
    242 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    243 #[cfg_attr(
    244     feature = "dto-bindgen",
    245     dto(ts(name = "RadrootsOrderRevisionDecisionEvent"))
    246 )]
    247 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    248 #[derive(Clone, Debug, PartialEq, Eq)]
    249 pub struct RadrootsOrderRevisionDecision {
    250     pub revision_id: RadrootsOrderRevisionId,
    251     pub order_id: RadrootsOrderId,
    252     pub listing_addr: RadrootsListingAddress,
    253     pub buyer_pubkey: RadrootsPublicKey,
    254     pub seller_pubkey: RadrootsPublicKey,
    255     pub root_event_id: RadrootsEventId,
    256     pub prev_event_id: RadrootsEventId,
    257     pub decision: RadrootsOrderRevisionOutcome,
    258 }
    259 
    260 impl RadrootsOrderRevisionDecision {
    261     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    262         validate_required_field(&self.revision_id, "revision_id")?;
    263         validate_required_field(&self.order_id, "order_id")?;
    264         validate_required_field(&self.listing_addr, "listing_addr")?;
    265         validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?;
    266         validate_required_field(&self.seller_pubkey, "seller_pubkey")?;
    267         validate_required_field(&self.root_event_id, "root_event_id")?;
    268         validate_required_field(&self.prev_event_id, "prev_event_id")?;
    269         self.decision.validate()
    270     }
    271 }
    272 
    273 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    274 #[cfg_attr(
    275     feature = "dto-bindgen",
    276     dto(ts(name = "RadrootsOrderInventoryCommitment"))
    277 )]
    278 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    279 #[derive(Clone, Debug, PartialEq, Eq)]
    280 pub struct RadrootsOrderInventoryCommitment {
    281     pub bin_id: RadrootsInventoryBinId,
    282     pub bin_count: u32,
    283 }
    284 
    285 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    286 #[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderDecisionOutcome")))]
    287 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    288 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))]
    289 #[derive(Clone, Debug, PartialEq, Eq)]
    290 pub enum RadrootsOrderDecisionOutcome {
    291     #[cfg_attr(feature = "serde", serde(rename = "accepted"))]
    292     Accepted {
    293         inventory_commitments: Vec<RadrootsOrderInventoryCommitment>,
    294     },
    295     #[cfg_attr(feature = "serde", serde(rename = "declined"))]
    296     Declined { reason: String },
    297 }
    298 
    299 impl RadrootsOrderDecisionOutcome {
    300     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    301         match self {
    302             Self::Accepted {
    303                 inventory_commitments,
    304             } => validate_inventory_commitments(inventory_commitments),
    305             Self::Declined { reason } => validate_required_field(reason, "reason"),
    306         }
    307     }
    308 }
    309 
    310 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    311 #[cfg_attr(
    312     feature = "dto-bindgen",
    313     dto(ts(name = "RadrootsOrderDecision"))
    314 )]
    315 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    316 #[derive(Clone, Debug, PartialEq, Eq)]
    317 pub struct RadrootsOrderDecision {
    318     pub order_id: RadrootsOrderId,
    319     pub listing_addr: RadrootsListingAddress,
    320     pub buyer_pubkey: RadrootsPublicKey,
    321     pub seller_pubkey: RadrootsPublicKey,
    322     pub decision: RadrootsOrderDecisionOutcome,
    323 }
    324 
    325 impl RadrootsOrderDecision {
    326     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    327         validate_required_field(&self.order_id, "order_id")?;
    328         validate_required_field(&self.listing_addr, "listing_addr")?;
    329         validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?;
    330         validate_required_field(&self.seller_pubkey, "seller_pubkey")?;
    331         self.decision.validate()
    332     }
    333 }
    334 
    335 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    336 #[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderCancellation")))]
    337 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    338 #[derive(Clone, Debug, PartialEq, Eq)]
    339 pub struct RadrootsOrderCancellation {
    340     pub order_id: RadrootsOrderId,
    341     pub listing_addr: RadrootsListingAddress,
    342     pub buyer_pubkey: RadrootsPublicKey,
    343     pub seller_pubkey: RadrootsPublicKey,
    344     pub reason: String,
    345 }
    346 
    347 impl RadrootsOrderCancellation {
    348     pub fn validate(&self) -> Result<(), RadrootsOrderPayloadError> {
    349         validate_required_field(&self.order_id, "order_id")?;
    350         validate_required_field(&self.listing_addr, "listing_addr")?;
    351         validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?;
    352         validate_required_field(&self.seller_pubkey, "seller_pubkey")?;
    353         validate_required_field(&self.reason, "reason")
    354     }
    355 }
    356 
    357 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    358 #[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsCommercialDomain")))]
    359 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    360 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    361 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    362 pub enum RadrootsCommercialDomain {
    363     #[cfg_attr(feature = "serde", serde(rename = "trade:listing"))]
    364     Listing,
    365 }
    366 
    367 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    368 #[cfg_attr(
    369     feature = "dto-bindgen",
    370     dto(ts(name = "RadrootsOrderEventType"))
    371 )]
    372 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    373 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    374 pub enum RadrootsOrderEventType {
    375     #[cfg_attr(feature = "serde", serde(rename = "TradeOrderRequested"))]
    376     OrderRequested,
    377     #[cfg_attr(feature = "serde", serde(rename = "TradeOrderDecision"))]
    378     OrderDecision,
    379     #[cfg_attr(feature = "serde", serde(rename = "TradeOrderRevisionProposed"))]
    380     OrderRevisionProposed,
    381     #[cfg_attr(feature = "serde", serde(rename = "TradeOrderRevisionDecision"))]
    382     OrderRevisionDecision,
    383     #[cfg_attr(feature = "serde", serde(rename = "TradeOrderCancelled"))]
    384     OrderCancelled,
    385 }
    386 
    387 impl RadrootsOrderEventType {
    388     #[inline]
    389     pub const fn from_kind(kind: u32) -> Option<Self> {
    390         match kind {
    391             KIND_ORDER_REQUEST => Some(Self::OrderRequested),
    392             KIND_ORDER_DECISION => Some(Self::OrderDecision),
    393             KIND_ORDER_REVISION_PROPOSAL => Some(Self::OrderRevisionProposed),
    394             KIND_ORDER_REVISION_DECISION => Some(Self::OrderRevisionDecision),
    395             KIND_ORDER_CANCELLATION => Some(Self::OrderCancelled),
    396             _ => None,
    397         }
    398     }
    399 
    400     #[inline]
    401     pub const fn kind(self) -> u32 {
    402         match self {
    403             Self::OrderRequested => KIND_ORDER_REQUEST,
    404             Self::OrderDecision => KIND_ORDER_DECISION,
    405             Self::OrderRevisionProposed => KIND_ORDER_REVISION_PROPOSAL,
    406             Self::OrderRevisionDecision => KIND_ORDER_REVISION_DECISION,
    407             Self::OrderCancelled => KIND_ORDER_CANCELLATION,
    408         }
    409     }
    410 
    411     #[inline]
    412     pub const fn name(self) -> &'static str {
    413         match self {
    414             Self::OrderRequested => "TradeOrderRequested",
    415             Self::OrderDecision => "TradeOrderDecision",
    416             Self::OrderRevisionProposed => "TradeOrderRevisionProposed",
    417             Self::OrderRevisionDecision => "TradeOrderRevisionDecision",
    418             Self::OrderCancelled => "TradeOrderCancelled",
    419         }
    420     }
    421 
    422     #[inline]
    423     pub const fn requires_listing_snapshot(self) -> bool {
    424         matches!(self, Self::OrderRequested)
    425     }
    426 
    427     #[inline]
    428     pub const fn requires_order_chain(self) -> bool {
    429         matches!(
    430             self,
    431             Self::OrderDecision
    432                 | Self::OrderRevisionProposed
    433                 | Self::OrderRevisionDecision
    434                 | Self::OrderCancelled
    435         )
    436     }
    437 }
    438 
    439 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    440 #[derive(Clone, Debug, PartialEq, Eq)]
    441 pub struct RadrootsOrderEnvelope<T> {
    442     pub version: u16,
    443     pub domain: RadrootsCommercialDomain,
    444     #[cfg_attr(feature = "serde", serde(rename = "type"))]
    445     pub message_type: RadrootsOrderEventType,
    446     pub order_id: String,
    447     pub listing_addr: String,
    448     pub payload: T,
    449 }
    450 
    451 impl<T> RadrootsOrderEnvelope<T> {
    452     #[inline]
    453     pub fn new(
    454         message_type: RadrootsOrderEventType,
    455         listing_addr: impl Into<String>,
    456         order_id: impl Into<String>,
    457         payload: T,
    458     ) -> Self {
    459         Self {
    460             version: RADROOTS_ORDER_ENVELOPE_VERSION,
    461             domain: RadrootsCommercialDomain::Listing,
    462             message_type,
    463             order_id: order_id.into(),
    464             listing_addr: listing_addr.into(),
    465             payload,
    466         }
    467     }
    468 
    469     pub fn validate(&self) -> Result<(), RadrootsOrderEnvelopeError> {
    470         if self.version != RADROOTS_ORDER_ENVELOPE_VERSION {
    471             return Err(RadrootsOrderEnvelopeError::InvalidVersion {
    472                 expected: RADROOTS_ORDER_ENVELOPE_VERSION,
    473                 got: self.version,
    474             });
    475         }
    476         if self.order_id.trim().is_empty() {
    477             return Err(RadrootsOrderEnvelopeError::MissingOrderId);
    478         }
    479         if self.listing_addr.trim().is_empty() {
    480             return Err(RadrootsOrderEnvelopeError::MissingListingAddr);
    481         }
    482         Ok(())
    483     }
    484 }
    485 
    486 #[derive(Debug, Clone, PartialEq, Eq)]
    487 pub enum RadrootsOrderEnvelopeError {
    488     InvalidVersion { expected: u16, got: u16 },
    489     MissingOrderId,
    490     MissingListingAddr,
    491 }
    492 
    493 impl core::fmt::Display for RadrootsOrderEnvelopeError {
    494     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    495         match self {
    496             Self::InvalidVersion { expected, got } => {
    497                 write!(
    498                     f,
    499                     "invalid order envelope version: expected {expected}, got {got}"
    500                 )
    501             }
    502             Self::MissingOrderId => write!(f, "missing order_id for order message"),
    503             Self::MissingListingAddr => write!(f, "missing listing_addr"),
    504         }
    505     }
    506 }
    507 
    508 #[cfg(feature = "std")]
    509 impl std::error::Error for RadrootsOrderEnvelopeError {}
    510 
    511 #[derive(Debug, Clone, PartialEq, Eq)]
    512 pub enum RadrootsOrderPayloadError {
    513     EmptyField(&'static str),
    514     MissingItems,
    515     InvalidItemBinCount { index: usize },
    516     MissingEconomicItems,
    517     InvalidEconomicItemBinCount { index: usize },
    518     InvalidEconomicItemQuantity { index: usize },
    519     InvalidEconomicItemPrice { index: usize },
    520     InvalidEconomicItemSubtotal { index: usize },
    521     InvalidEconomicLineAmount { field: &'static str, index: usize },
    522     InvalidEconomicLineKind { field: &'static str, index: usize },
    523     InvalidEconomicLineEffect { field: &'static str, index: usize },
    524     InvalidEconomicCurrency { field: &'static str },
    525     InvalidEconomicOrdering { field: &'static str },
    526     InvalidEconomicTotal { field: &'static str },
    527     InvalidOrderEconomicsBinding { field: &'static str },
    528     InvalidQuoteVersion,
    529     MissingInventoryCommitments,
    530     InvalidInventoryCommitmentCount { index: usize },
    531 }
    532 
    533 impl core::fmt::Display for RadrootsOrderPayloadError {
    534     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    535         match self {
    536             Self::EmptyField(field) => write!(f, "{field} cannot be empty"),
    537             Self::MissingItems => write!(f, "items must contain at least one item"),
    538             Self::InvalidItemBinCount { index } => {
    539                 write!(f, "items[{index}].bin_count must be greater than zero")
    540             }
    541             Self::MissingEconomicItems => {
    542                 write!(f, "economics.items must contain at least one item")
    543             }
    544             Self::InvalidEconomicItemBinCount { index } => write!(
    545                 f,
    546                 "economics.items[{index}].bin_count must be greater than zero"
    547             ),
    548             Self::InvalidEconomicItemQuantity { index } => write!(
    549                 f,
    550                 "economics.items[{index}].quantity_amount must be greater than zero"
    551             ),
    552             Self::InvalidEconomicItemPrice { index } => write!(
    553                 f,
    554                 "economics.items[{index}].unit_price_amount must not be negative"
    555             ),
    556             Self::InvalidEconomicItemSubtotal { index } => {
    557                 write!(f, "economics.items[{index}].line_subtotal is invalid")
    558             }
    559             Self::InvalidEconomicLineAmount { field, index } => {
    560                 write!(
    561                     f,
    562                     "economics.{field}[{index}].amount must be greater than zero"
    563                 )
    564             }
    565             Self::InvalidEconomicLineKind { field, index } => {
    566                 write!(f, "economics.{field}[{index}].kind is invalid")
    567             }
    568             Self::InvalidEconomicLineEffect { field, index } => {
    569                 write!(f, "economics.{field}[{index}].effect is invalid")
    570             }
    571             Self::InvalidEconomicCurrency { field } => {
    572                 write!(f, "economics.{field} currency is invalid")
    573             }
    574             Self::InvalidEconomicOrdering { field } => {
    575                 write!(f, "economics.{field} is not in canonical order")
    576             }
    577             Self::InvalidEconomicTotal { field } => {
    578                 write!(f, "economics.{field} total is invalid")
    579             }
    580             Self::InvalidOrderEconomicsBinding { field } => {
    581                 write!(f, "order {field} does not match economics")
    582             }
    583             Self::InvalidQuoteVersion => {
    584                 write!(f, "economics.quote_version must be greater than zero")
    585             }
    586             Self::MissingInventoryCommitments => {
    587                 write!(
    588                     f,
    589                     "accepted decisions must contain at least one inventory commitment"
    590                 )
    591             }
    592             Self::InvalidInventoryCommitmentCount { index } => write!(
    593                 f,
    594                 "inventory_commitments[{index}].bin_count must be greater than zero"
    595             ),
    596         }
    597     }
    598 }
    599 
    600 #[cfg(feature = "std")]
    601 impl std::error::Error for RadrootsOrderPayloadError {}
    602 
    603 fn validate_required_field(
    604     value: &str,
    605     field: &'static str,
    606 ) -> Result<(), RadrootsOrderPayloadError> {
    607     if value.trim().is_empty() {
    608         Err(RadrootsOrderPayloadError::EmptyField(field))
    609     } else {
    610         Ok(())
    611     }
    612 }
    613 
    614 fn validate_order_items(items: &[RadrootsOrderItem]) -> Result<(), RadrootsOrderPayloadError> {
    615     if items.is_empty() {
    616         return Err(RadrootsOrderPayloadError::MissingItems);
    617     }
    618     for (index, item) in items.iter().enumerate() {
    619         validate_required_field(&item.bin_id, "bin_id")?;
    620         if item.bin_count == 0 {
    621             return Err(RadrootsOrderPayloadError::InvalidItemBinCount { index });
    622         }
    623     }
    624     Ok(())
    625 }
    626 
    627 fn validate_economic_item(
    628     item: &RadrootsOrderEconomicItem,
    629     expected_currency: RadrootsCoreCurrency,
    630     index: usize,
    631 ) -> Result<RadrootsCoreMoney, RadrootsOrderPayloadError> {
    632     validate_required_field(&item.bin_id, "economics.items.bin_id")?;
    633     if item.bin_count == 0 {
    634         return Err(RadrootsOrderPayloadError::InvalidEconomicItemBinCount { index });
    635     }
    636     if item.quantity_amount.is_zero() || item.quantity_amount.is_sign_negative() {
    637         return Err(RadrootsOrderPayloadError::InvalidEconomicItemQuantity { index });
    638     }
    639     if item.unit_price_amount.is_sign_negative() {
    640         return Err(RadrootsOrderPayloadError::InvalidEconomicItemPrice { index });
    641     }
    642     if item.unit_price_currency != expected_currency {
    643         return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency {
    644             field: "items.unit_price_currency",
    645         });
    646     }
    647     validate_total_money(
    648         &item.line_subtotal,
    649         expected_currency,
    650         "items.line_subtotal",
    651     )?;
    652 
    653     let quantity_total = checked_decimal_mul(
    654         item.quantity_amount,
    655         RadrootsCoreDecimal::from(item.bin_count),
    656     )
    657     .ok_or(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index })?;
    658     let expected_subtotal = checked_decimal_mul(item.unit_price_amount, quantity_total)
    659         .ok_or(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index })?;
    660     if item.line_subtotal.amount != expected_subtotal {
    661         return Err(RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index });
    662     }
    663     Ok(item.line_subtotal.clone())
    664 }
    665 
    666 fn validate_order_economics_binding(
    667     items: &[RadrootsOrderItem],
    668     economics: &RadrootsOrderEconomics,
    669 ) -> Result<(), RadrootsOrderPayloadError> {
    670     let order_items = normalized_order_item_counts(items).ok_or(
    671         RadrootsOrderPayloadError::InvalidOrderEconomicsBinding {
    672             field: "items.bin_count",
    673         },
    674     )?;
    675     if order_items.len() != economics.items.len() {
    676         return Err(RadrootsOrderPayloadError::InvalidOrderEconomicsBinding { field: "items" });
    677     }
    678     for (item, economic_item) in order_items.iter().zip(economics.items.iter()) {
    679         if item.bin_id != economic_item.bin_id {
    680             return Err(RadrootsOrderPayloadError::InvalidOrderEconomicsBinding {
    681                 field: "items.bin_id",
    682             });
    683         }
    684         if item.bin_count != u64::from(economic_item.bin_count) {
    685             return Err(RadrootsOrderPayloadError::InvalidOrderEconomicsBinding {
    686                 field: "items.bin_count",
    687             });
    688         }
    689     }
    690     Ok(())
    691 }
    692 
    693 #[derive(Debug, PartialEq, Eq)]
    694 struct NormalizedOrderItemCount {
    695     bin_id: String,
    696     bin_count: u64,
    697 }
    698 
    699 fn normalized_order_item_counts(
    700     items: &[RadrootsOrderItem],
    701 ) -> Option<Vec<NormalizedOrderItemCount>> {
    702     let mut counts: Vec<NormalizedOrderItemCount> = Vec::new();
    703     for item in items {
    704         let bin_id = item.bin_id.trim();
    705         if item.bin_count == 0 {
    706             return None;
    707         }
    708         if let Some(existing) = counts.iter_mut().find(|count| count.bin_id == bin_id) {
    709             existing.bin_count = existing.bin_count.checked_add(u64::from(item.bin_count))?;
    710         } else {
    711             counts.push(NormalizedOrderItemCount {
    712                 bin_id: bin_id.to_string(),
    713                 bin_count: u64::from(item.bin_count),
    714             });
    715         }
    716     }
    717     counts.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
    718     Some(counts)
    719 }
    720 
    721 fn validate_economic_line(
    722     line: &RadrootsOrderEconomicLine,
    723     expected_currency: RadrootsCoreCurrency,
    724     field: &'static str,
    725     index: usize,
    726 ) -> Result<(), RadrootsOrderPayloadError> {
    727     validate_required_field(&line.id, "economics.line.id")?;
    728     validate_required_field(&line.reason, "economics.line.reason")?;
    729     if line.amount.currency != expected_currency {
    730         return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field });
    731     }
    732     if line.amount.amount.is_zero() || line.amount.amount.is_sign_negative() {
    733         return Err(RadrootsOrderPayloadError::InvalidEconomicLineAmount { field, index });
    734     }
    735     Ok(())
    736 }
    737 
    738 fn validate_economic_item_order(
    739     items: &[RadrootsOrderEconomicItem],
    740 ) -> Result<(), RadrootsOrderPayloadError> {
    741     for pair in items.windows(2) {
    742         if pair[0].bin_id >= pair[1].bin_id {
    743             return Err(RadrootsOrderPayloadError::InvalidEconomicOrdering {
    744                 field: "items.bin_id",
    745             });
    746         }
    747     }
    748     Ok(())
    749 }
    750 
    751 fn validate_economic_line_order(
    752     lines: &[RadrootsOrderEconomicLine],
    753     field: &'static str,
    754 ) -> Result<(), RadrootsOrderPayloadError> {
    755     for pair in lines.windows(2) {
    756         if pair[0].id >= pair[1].id {
    757             return Err(RadrootsOrderPayloadError::InvalidEconomicOrdering { field });
    758         }
    759     }
    760     Ok(())
    761 }
    762 
    763 fn validate_total_money(
    764     money: &RadrootsCoreMoney,
    765     expected_currency: RadrootsCoreCurrency,
    766     field: &'static str,
    767 ) -> Result<(), RadrootsOrderPayloadError> {
    768     if money.currency != expected_currency {
    769         return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field });
    770     }
    771     if money.amount.is_sign_negative() {
    772         return Err(RadrootsOrderPayloadError::InvalidEconomicTotal { field });
    773     }
    774     Ok(())
    775 }
    776 
    777 fn validate_total_matches(
    778     actual: &RadrootsCoreMoney,
    779     expected: &RadrootsCoreMoney,
    780     field: &'static str,
    781 ) -> Result<(), RadrootsOrderPayloadError> {
    782     if actual.currency != expected.currency {
    783         return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field });
    784     }
    785     if actual.amount != expected.amount {
    786         return Err(RadrootsOrderPayloadError::InvalidEconomicTotal { field });
    787     }
    788     Ok(())
    789 }
    790 
    791 fn checked_decimal_add(
    792     left: RadrootsCoreDecimal,
    793     right: RadrootsCoreDecimal,
    794 ) -> Option<RadrootsCoreDecimal> {
    795     left.0.checked_add(right.0).map(RadrootsCoreDecimal)
    796 }
    797 
    798 fn checked_decimal_sub(
    799     left: RadrootsCoreDecimal,
    800     right: RadrootsCoreDecimal,
    801 ) -> Option<RadrootsCoreDecimal> {
    802     left.0.checked_sub(right.0).map(RadrootsCoreDecimal)
    803 }
    804 
    805 fn checked_decimal_mul(
    806     left: RadrootsCoreDecimal,
    807     right: RadrootsCoreDecimal,
    808 ) -> Option<RadrootsCoreDecimal> {
    809     left.0.checked_mul(right.0).map(RadrootsCoreDecimal)
    810 }
    811 
    812 fn checked_money_add(
    813     left: &RadrootsCoreMoney,
    814     right: &RadrootsCoreMoney,
    815     field: &'static str,
    816 ) -> Result<RadrootsCoreMoney, RadrootsOrderPayloadError> {
    817     if left.currency != right.currency {
    818         return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field });
    819     }
    820     let amount = checked_decimal_add(left.amount, right.amount)
    821         .ok_or(RadrootsOrderPayloadError::InvalidEconomicTotal { field })?;
    822     Ok(RadrootsCoreMoney::new(amount, left.currency))
    823 }
    824 
    825 fn checked_money_sub_non_negative(
    826     left: &RadrootsCoreMoney,
    827     right: &RadrootsCoreMoney,
    828     field: &'static str,
    829 ) -> Result<RadrootsCoreMoney, RadrootsOrderPayloadError> {
    830     if left.currency != right.currency {
    831         return Err(RadrootsOrderPayloadError::InvalidEconomicCurrency { field });
    832     }
    833     let amount = checked_decimal_sub(left.amount, right.amount)
    834         .ok_or(RadrootsOrderPayloadError::InvalidEconomicTotal { field })?;
    835     if amount.is_sign_negative() {
    836         return Err(RadrootsOrderPayloadError::InvalidEconomicTotal { field });
    837     }
    838     Ok(RadrootsCoreMoney::new(amount, left.currency))
    839 }
    840 
    841 fn validate_inventory_commitments(
    842     commitments: &[RadrootsOrderInventoryCommitment],
    843 ) -> Result<(), RadrootsOrderPayloadError> {
    844     if commitments.is_empty() {
    845         return Err(RadrootsOrderPayloadError::MissingInventoryCommitments);
    846     }
    847     for (index, commitment) in commitments.iter().enumerate() {
    848         validate_required_field(&commitment.bin_id, "bin_id")?;
    849         if commitment.bin_count == 0 {
    850             return Err(RadrootsOrderPayloadError::InvalidInventoryCommitmentCount { index });
    851         }
    852     }
    853     Ok(())
    854 }
    855 
    856 #[cfg(test)]
    857 mod tests {
    858     use super::*;
    859     use radroots_core::{
    860         RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
    861     };
    862 
    863     fn pubkey(character: char) -> RadrootsPublicKey {
    864         core::iter::repeat_n(character, 64)
    865             .collect::<String>()
    866             .parse()
    867             .unwrap()
    868     }
    869 
    870     fn event_id(character: char) -> RadrootsEventId {
    871         core::iter::repeat_n(character, 64)
    872             .collect::<String>()
    873             .parse()
    874             .unwrap()
    875     }
    876 
    877     fn buyer_pubkey() -> RadrootsPublicKey {
    878         pubkey('b')
    879     }
    880 
    881     fn seller_pubkey() -> RadrootsPublicKey {
    882         pubkey('a')
    883     }
    884 
    885     fn sample_listing_addr() -> RadrootsListingAddress {
    886         format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAg", seller_pubkey())
    887             .parse()
    888             .unwrap()
    889     }
    890 
    891     fn order_id(raw: &str) -> RadrootsOrderId {
    892         raw.parse().unwrap()
    893     }
    894 
    895     fn revision_id(raw: &str) -> RadrootsOrderRevisionId {
    896         raw.parse().unwrap()
    897     }
    898 
    899     fn quote_id(raw: &str) -> RadrootsOrderQuoteId {
    900         raw.parse().unwrap()
    901     }
    902 
    903     fn bin_id(raw: &str) -> RadrootsInventoryBinId {
    904         raw.parse().unwrap()
    905     }
    906 
    907     fn sample_order_request() -> RadrootsOrderRequest {
    908         RadrootsOrderRequest {
    909             order_id: order_id("order-1"),
    910             listing_addr: sample_listing_addr(),
    911             buyer_pubkey: buyer_pubkey(),
    912             seller_pubkey: seller_pubkey(),
    913             items: vec![RadrootsOrderItem {
    914                 bin_id: bin_id("bin-1"),
    915                 bin_count: 2,
    916             }],
    917             economics: sample_bound_order_economics(),
    918         }
    919     }
    920 
    921     fn decimal(raw: &str) -> RadrootsCoreDecimal {
    922         raw.parse().unwrap()
    923     }
    924 
    925     fn usd(raw: &str) -> RadrootsCoreMoney {
    926         RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD)
    927     }
    928 
    929     fn sample_order_economics() -> RadrootsOrderEconomics {
    930         RadrootsOrderEconomics {
    931             quote_id: quote_id("quote-1"),
    932             quote_version: 1,
    933             pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
    934             currency: RadrootsCoreCurrency::USD,
    935             items: vec![
    936                 RadrootsOrderEconomicItem {
    937                     bin_id: bin_id("bin-a"),
    938                     bin_count: 2,
    939                     quantity_amount: decimal("1.5"),
    940                     quantity_unit: RadrootsCoreUnit::Each,
    941                     unit_price_amount: decimal("4"),
    942                     unit_price_currency: RadrootsCoreCurrency::USD,
    943                     line_subtotal: usd("12"),
    944                 },
    945                 RadrootsOrderEconomicItem {
    946                     bin_id: bin_id("bin-b"),
    947                     bin_count: 1,
    948                     quantity_amount: decimal("2"),
    949                     quantity_unit: RadrootsCoreUnit::Each,
    950                     unit_price_amount: decimal("3"),
    951                     unit_price_currency: RadrootsCoreCurrency::USD,
    952                     line_subtotal: usd("6"),
    953                 },
    954             ],
    955             discounts: vec![RadrootsOrderEconomicLine {
    956                 id: "discount-a".into(),
    957                 kind: RadrootsOrderEconomicLineKind::ListingDiscount,
    958                 actor: RadrootsOrderEconomicActor::Seller,
    959                 effect: RadrootsOrderEconomicEffect::Decrease,
    960                 amount: usd("3"),
    961                 reason: "farmstand pickup".into(),
    962             }],
    963             adjustments: vec![
    964                 RadrootsOrderEconomicLine {
    965                     id: "adjustment-a".into(),
    966                     kind: RadrootsOrderEconomicLineKind::BasketAdjustment,
    967                     actor: RadrootsOrderEconomicActor::Buyer,
    968                     effect: RadrootsOrderEconomicEffect::Increase,
    969                     amount: usd("2"),
    970                     reason: "special handling".into(),
    971                 },
    972                 RadrootsOrderEconomicLine {
    973                     id: "adjustment-b".into(),
    974                     kind: RadrootsOrderEconomicLineKind::BasketAdjustment,
    975                     actor: RadrootsOrderEconomicActor::Buyer,
    976                     effect: RadrootsOrderEconomicEffect::Decrease,
    977                     amount: usd("1"),
    978                     reason: "local pickup credit".into(),
    979                 },
    980             ],
    981             subtotal: usd("18"),
    982             discount_total: usd("3"),
    983             adjustment_total: usd("3"),
    984             total: usd("16"),
    985         }
    986     }
    987 
    988     fn sample_bound_order_economics() -> RadrootsOrderEconomics {
    989         RadrootsOrderEconomics {
    990             quote_id: quote_id("quote-bound-1"),
    991             quote_version: 1,
    992             pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
    993             currency: RadrootsCoreCurrency::USD,
    994             items: vec![RadrootsOrderEconomicItem {
    995                 bin_id: bin_id("bin-1"),
    996                 bin_count: 2,
    997                 quantity_amount: decimal("1"),
    998                 quantity_unit: RadrootsCoreUnit::Each,
    999                 unit_price_amount: decimal("5"),
   1000                 unit_price_currency: RadrootsCoreCurrency::USD,
   1001                 line_subtotal: usd("10"),
   1002             }],
   1003             discounts: Vec::new(),
   1004             adjustments: Vec::new(),
   1005             subtotal: usd("10"),
   1006             discount_total: usd("0"),
   1007             adjustment_total: usd("0"),
   1008             total: usd("10"),
   1009         }
   1010     }
   1011 
   1012     fn sample_inventory_commitment() -> RadrootsOrderInventoryCommitment {
   1013         RadrootsOrderInventoryCommitment {
   1014             bin_id: bin_id("bin-1"),
   1015             bin_count: 2,
   1016         }
   1017     }
   1018 
   1019     fn sample_order_decision() -> RadrootsOrderDecision {
   1020         RadrootsOrderDecision {
   1021             order_id: order_id("order-1"),
   1022             listing_addr: sample_listing_addr(),
   1023             buyer_pubkey: buyer_pubkey(),
   1024             seller_pubkey: seller_pubkey(),
   1025             decision: RadrootsOrderDecisionOutcome::Accepted {
   1026                 inventory_commitments: vec![sample_inventory_commitment()],
   1027             },
   1028         }
   1029     }
   1030 
   1031     fn sample_order_cancellation() -> RadrootsOrderCancellation {
   1032         RadrootsOrderCancellation {
   1033             order_id: order_id("order-1"),
   1034             listing_addr: sample_listing_addr(),
   1035             buyer_pubkey: buyer_pubkey(),
   1036             seller_pubkey: seller_pubkey(),
   1037             reason: "changed plans".into(),
   1038         }
   1039     }
   1040 
   1041     fn sample_order_revision_proposal() -> RadrootsOrderRevisionProposal {
   1042         RadrootsOrderRevisionProposal {
   1043             revision_id: revision_id("rev-1"),
   1044             order_id: order_id("order-1"),
   1045             listing_addr: sample_listing_addr(),
   1046             buyer_pubkey: buyer_pubkey(),
   1047             seller_pubkey: seller_pubkey(),
   1048             root_event_id: event_id('1'),
   1049             prev_event_id: event_id('2'),
   1050             items: vec![RadrootsOrderItem {
   1051                 bin_id: bin_id("bin-1"),
   1052                 bin_count: 2,
   1053             }],
   1054             economics: sample_bound_order_economics(),
   1055             reason: "update quantity".into(),
   1056         }
   1057     }
   1058 
   1059     fn sample_order_revision_decision(
   1060         decision: RadrootsOrderRevisionOutcome,
   1061     ) -> RadrootsOrderRevisionDecision {
   1062         RadrootsOrderRevisionDecision {
   1063             revision_id: revision_id("rev-1"),
   1064             order_id: order_id("order-1"),
   1065             listing_addr: sample_listing_addr(),
   1066             buyer_pubkey: buyer_pubkey(),
   1067             seller_pubkey: seller_pubkey(),
   1068             root_event_id: event_id('1'),
   1069             prev_event_id: event_id('2'),
   1070             decision,
   1071         }
   1072     }
   1073 
   1074     #[test]
   1075     fn order_message_type_uses_canonical_names_and_kinds() {
   1076         assert_eq!(
   1077             RadrootsOrderEventType::from_kind(KIND_ORDER_REQUEST),
   1078             Some(RadrootsOrderEventType::OrderRequested)
   1079         );
   1080         assert_eq!(
   1081             RadrootsOrderEventType::from_kind(KIND_ORDER_DECISION),
   1082             Some(RadrootsOrderEventType::OrderDecision)
   1083         );
   1084         assert_eq!(
   1085             RadrootsOrderEventType::from_kind(KIND_ORDER_REVISION_PROPOSAL),
   1086             Some(RadrootsOrderEventType::OrderRevisionProposed)
   1087         );
   1088         assert_eq!(
   1089             RadrootsOrderEventType::from_kind(KIND_ORDER_REVISION_DECISION),
   1090             Some(RadrootsOrderEventType::OrderRevisionDecision)
   1091         );
   1092         assert_eq!(
   1093             RadrootsOrderEventType::from_kind(KIND_ORDER_CANCELLATION),
   1094             Some(RadrootsOrderEventType::OrderCancelled)
   1095         );
   1096         assert_eq!(RadrootsOrderEventType::from_kind(3433), None);
   1097         assert_eq!(RadrootsOrderEventType::from_kind(3434), None);
   1098         assert_eq!(RadrootsOrderEventType::from_kind(3435), None);
   1099         assert_eq!(RadrootsOrderEventType::from_kind(3436), None);
   1100         assert_eq!(RadrootsOrderEventType::from_kind(3431), None);
   1101         assert_eq!(
   1102             RadrootsOrderEventType::OrderRequested.kind(),
   1103             KIND_ORDER_REQUEST
   1104         );
   1105         assert_eq!(
   1106             RadrootsOrderEventType::OrderDecision.kind(),
   1107             KIND_ORDER_DECISION
   1108         );
   1109         assert_eq!(
   1110             RadrootsOrderEventType::OrderRevisionProposed.kind(),
   1111             KIND_ORDER_REVISION_PROPOSAL
   1112         );
   1113         assert_eq!(
   1114             RadrootsOrderEventType::OrderRevisionDecision.kind(),
   1115             KIND_ORDER_REVISION_DECISION
   1116         );
   1117         assert_eq!(
   1118             RadrootsOrderEventType::OrderCancelled.kind(),
   1119             KIND_ORDER_CANCELLATION
   1120         );
   1121         assert_eq!(
   1122             RadrootsOrderEventType::OrderRequested.name(),
   1123             "TradeOrderRequested"
   1124         );
   1125         assert_eq!(
   1126             RadrootsOrderEventType::OrderDecision.name(),
   1127             "TradeOrderDecision"
   1128         );
   1129         assert_eq!(
   1130             RadrootsOrderEventType::OrderRevisionProposed.name(),
   1131             "TradeOrderRevisionProposed"
   1132         );
   1133         assert_eq!(
   1134             RadrootsOrderEventType::OrderRevisionDecision.name(),
   1135             "TradeOrderRevisionDecision"
   1136         );
   1137         assert_eq!(
   1138             RadrootsOrderEventType::OrderCancelled.name(),
   1139             "TradeOrderCancelled"
   1140         );
   1141         assert!(RadrootsOrderEventType::OrderRequested.requires_listing_snapshot());
   1142         assert!(RadrootsOrderEventType::OrderDecision.requires_order_chain());
   1143         assert!(RadrootsOrderEventType::OrderRevisionProposed.requires_order_chain());
   1144         assert!(RadrootsOrderEventType::OrderRevisionDecision.requires_order_chain());
   1145         assert!(RadrootsOrderEventType::OrderCancelled.requires_order_chain());
   1146         assert!(!RadrootsOrderEventType::OrderRequested.requires_order_chain());
   1147 
   1148         let request_name = serde_json::to_value(RadrootsOrderEventType::OrderRequested).unwrap();
   1149         let decision_name = serde_json::to_value(RadrootsOrderEventType::OrderDecision).unwrap();
   1150         let revision_proposed_name =
   1151             serde_json::to_value(RadrootsOrderEventType::OrderRevisionProposed).unwrap();
   1152         let revision_decision_name =
   1153             serde_json::to_value(RadrootsOrderEventType::OrderRevisionDecision).unwrap();
   1154         let cancellation_name =
   1155             serde_json::to_value(RadrootsOrderEventType::OrderCancelled).unwrap();
   1156         assert_eq!(request_name, serde_json::json!("TradeOrderRequested"));
   1157         assert_eq!(decision_name, serde_json::json!("TradeOrderDecision"));
   1158         assert_eq!(
   1159             revision_proposed_name,
   1160             serde_json::json!("TradeOrderRevisionProposed")
   1161         );
   1162         assert_eq!(
   1163             revision_decision_name,
   1164             serde_json::json!("TradeOrderRevisionDecision")
   1165         );
   1166         assert_eq!(cancellation_name, serde_json::json!("TradeOrderCancelled"));
   1167     }
   1168 
   1169     #[test]
   1170     fn order_request_validation_rejects_invalid_fields() {
   1171         assert_eq!(sample_order_request().validate(), Ok(()));
   1172 
   1173         let mut missing_items = sample_order_request();
   1174         missing_items.items.clear();
   1175         assert_eq!(
   1176             missing_items.validate().unwrap_err(),
   1177             RadrootsOrderPayloadError::MissingItems
   1178         );
   1179 
   1180         let mut invalid_count = sample_order_request();
   1181         invalid_count.items[0].bin_count = 0;
   1182         assert_eq!(
   1183             invalid_count.validate().unwrap_err(),
   1184             RadrootsOrderPayloadError::InvalidItemBinCount { index: 0 }
   1185         );
   1186 
   1187         let mut mismatched_economic_item = sample_order_request();
   1188         mismatched_economic_item.economics.items[0].bin_id = bin_id("bin-other");
   1189         assert_eq!(
   1190             mismatched_economic_item.validate().unwrap_err(),
   1191             RadrootsOrderPayloadError::InvalidOrderEconomicsBinding {
   1192                 field: "items.bin_id"
   1193             }
   1194         );
   1195 
   1196         let mut mismatched_economic_count = sample_order_request();
   1197         mismatched_economic_count.economics.items[0].bin_count = 3;
   1198         mismatched_economic_count.economics.items[0].line_subtotal = usd("15");
   1199         mismatched_economic_count.economics.subtotal = usd("15");
   1200         mismatched_economic_count.economics.total = usd("15");
   1201         assert_eq!(
   1202             mismatched_economic_count.validate().unwrap_err(),
   1203             RadrootsOrderPayloadError::InvalidOrderEconomicsBinding {
   1204                 field: "items.bin_count"
   1205             }
   1206         );
   1207     }
   1208 
   1209     #[test]
   1210     fn order_payload_json_rejects_invalid_protocol_identifiers() {
   1211         let mut request = serde_json::to_value(sample_order_request()).unwrap();
   1212         request["buyer_pubkey"] = serde_json::json!("not-a-pubkey");
   1213         assert!(serde_json::from_value::<RadrootsOrderRequest>(request).is_err());
   1214 
   1215         let mut revision = serde_json::to_value(sample_order_revision_proposal()).unwrap();
   1216         revision["root_event_id"] = serde_json::json!("not-an-event-id");
   1217         assert!(serde_json::from_value::<RadrootsOrderRevisionProposal>(revision).is_err());
   1218     }
   1219 
   1220     #[test]
   1221     fn order_economics_validation_accepts_canonical_totals() {
   1222         let economics = sample_order_economics();
   1223         assert_eq!(economics.validate(), Ok(()));
   1224 
   1225         let totals = economics.derived_totals().unwrap();
   1226         assert_eq!(totals.subtotal, usd("18"));
   1227         assert_eq!(totals.discount_total, usd("3"));
   1228         assert_eq!(totals.adjustment_total, usd("3"));
   1229         assert_eq!(totals.total, usd("16"));
   1230 
   1231         let json = serde_json::to_value(&economics).unwrap();
   1232         assert_eq!(json["pricing_basis"], serde_json::json!("listing_event"));
   1233         assert_eq!(
   1234             json["discounts"][0]["kind"],
   1235             serde_json::json!("listing_discount")
   1236         );
   1237         assert_eq!(
   1238             json["adjustments"][0]["effect"],
   1239             serde_json::json!("increase")
   1240         );
   1241     }
   1242 
   1243     #[test]
   1244     fn order_economics_canonicalized_sorts_items_and_lines() {
   1245         let mut economics = sample_order_economics();
   1246         economics.items.reverse();
   1247         economics.adjustments.reverse();
   1248         economics.discounts.push(RadrootsOrderEconomicLine {
   1249             id: "discount-b".into(),
   1250             kind: RadrootsOrderEconomicLineKind::ListingDiscount,
   1251             actor: RadrootsOrderEconomicActor::Seller,
   1252             effect: RadrootsOrderEconomicEffect::Decrease,
   1253             amount: usd("1"),
   1254             reason: "market credit".into(),
   1255         });
   1256         economics.discounts.reverse();
   1257         economics.subtotal = usd("19");
   1258         economics.total = usd("17");
   1259         assert_eq!(
   1260             economics.validate().unwrap_err(),
   1261             RadrootsOrderPayloadError::InvalidEconomicOrdering {
   1262                 field: "items.bin_id"
   1263             }
   1264         );
   1265 
   1266         let canonical = economics.canonicalized();
   1267         assert_eq!(canonical.items[0].bin_id, "bin-a");
   1268         assert_eq!(canonical.discounts[0].id, "discount-a");
   1269         assert_eq!(canonical.adjustments[0].id, "adjustment-a");
   1270         assert_eq!(canonical.subtotal, usd("18"));
   1271         assert_eq!(canonical.discount_total, usd("4"));
   1272         assert_eq!(canonical.total, usd("15"));
   1273         assert_eq!(canonical.validate(), Ok(()));
   1274 
   1275         let mut uncanonicalizable = sample_order_economics();
   1276         uncanonicalizable.items.clear();
   1277         uncanonicalizable.subtotal = usd("88");
   1278         uncanonicalizable.canonicalize();
   1279         assert_eq!(uncanonicalizable.subtotal, usd("88"));
   1280     }
   1281 
   1282     #[test]
   1283     fn order_economics_validation_rejects_mixed_currency() {
   1284         let mut economics = sample_order_economics();
   1285         economics.items[0].unit_price_currency = RadrootsCoreCurrency::EUR;
   1286         assert_eq!(
   1287             economics.validate().unwrap_err(),
   1288             RadrootsOrderPayloadError::InvalidEconomicCurrency {
   1289                 field: "items.unit_price_currency"
   1290             }
   1291         );
   1292 
   1293         let mut economics = sample_order_economics();
   1294         economics.adjustments[0].amount =
   1295             RadrootsCoreMoney::new(decimal("2"), RadrootsCoreCurrency::EUR);
   1296         assert_eq!(
   1297             economics.validate().unwrap_err(),
   1298             RadrootsOrderPayloadError::InvalidEconomicCurrency {
   1299                 field: "adjustments"
   1300             }
   1301         );
   1302     }
   1303 
   1304     #[test]
   1305     fn order_economics_validation_rejects_bad_subtotal() {
   1306         let mut economics = sample_order_economics();
   1307         economics.items[0].bin_count = 0;
   1308         assert_eq!(
   1309             economics.validate().unwrap_err(),
   1310             RadrootsOrderPayloadError::InvalidEconomicItemBinCount { index: 0 }
   1311         );
   1312 
   1313         let mut economics = sample_order_economics();
   1314         economics.items[0].line_subtotal = usd("11.99");
   1315         assert_eq!(
   1316             economics.validate().unwrap_err(),
   1317             RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index: 0 }
   1318         );
   1319 
   1320         let mut economics = sample_order_economics();
   1321         economics.items[0].line_subtotal =
   1322             RadrootsCoreMoney::new(decimal("12"), RadrootsCoreCurrency::EUR);
   1323         assert_eq!(
   1324             economics.validate().unwrap_err(),
   1325             RadrootsOrderPayloadError::InvalidEconomicCurrency {
   1326                 field: "items.line_subtotal"
   1327             }
   1328         );
   1329     }
   1330 
   1331     #[test]
   1332     fn order_economics_validation_covers_remaining_error_paths() {
   1333         let mut economics = sample_order_economics();
   1334         economics.items.clear();
   1335         assert_eq!(
   1336             economics.derived_totals().unwrap_err(),
   1337             RadrootsOrderPayloadError::MissingEconomicItems
   1338         );
   1339 
   1340         let mut economics = sample_order_economics();
   1341         economics.quote_version = 0;
   1342         assert_eq!(
   1343             economics.validate().unwrap_err(),
   1344             RadrootsOrderPayloadError::InvalidQuoteVersion
   1345         );
   1346 
   1347         let mut economics = sample_order_economics();
   1348         economics.items[0].quantity_amount = decimal("0");
   1349         assert_eq!(
   1350             economics.validate().unwrap_err(),
   1351             RadrootsOrderPayloadError::InvalidEconomicItemQuantity { index: 0 }
   1352         );
   1353 
   1354         let mut economics = sample_order_economics();
   1355         economics.items[0].quantity_amount = decimal("-1");
   1356         assert_eq!(
   1357             economics.validate().unwrap_err(),
   1358             RadrootsOrderPayloadError::InvalidEconomicItemQuantity { index: 0 }
   1359         );
   1360 
   1361         let mut economics = sample_order_economics();
   1362         economics.items[0].unit_price_amount = decimal("-1");
   1363         assert_eq!(
   1364             economics.validate().unwrap_err(),
   1365             RadrootsOrderPayloadError::InvalidEconomicItemPrice { index: 0 }
   1366         );
   1367 
   1368         let mut economics = sample_order_economics();
   1369         economics.discounts[0].kind = RadrootsOrderEconomicLineKind::BasketAdjustment;
   1370         assert_eq!(
   1371             economics.validate().unwrap_err(),
   1372             RadrootsOrderPayloadError::InvalidEconomicLineKind {
   1373                 field: "discounts",
   1374                 index: 0
   1375             }
   1376         );
   1377 
   1378         let mut economics = sample_order_economics();
   1379         economics.subtotal = RadrootsCoreMoney::new(decimal("18"), RadrootsCoreCurrency::EUR);
   1380         assert_eq!(
   1381             economics.validate().unwrap_err(),
   1382             RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "subtotal" }
   1383         );
   1384 
   1385         let mut economics = sample_order_economics();
   1386         economics.subtotal = usd("-1");
   1387         assert_eq!(
   1388             economics.validate().unwrap_err(),
   1389             RadrootsOrderPayloadError::InvalidEconomicTotal { field: "subtotal" }
   1390         );
   1391 
   1392         let mut economics = sample_order_economics();
   1393         economics.discount_total = usd("4");
   1394         assert_eq!(
   1395             economics.validate().unwrap_err(),
   1396             RadrootsOrderPayloadError::InvalidEconomicTotal {
   1397                 field: "discount_total"
   1398             }
   1399         );
   1400 
   1401         let mut economics = sample_order_economics();
   1402         economics.adjustment_total = usd("4");
   1403         assert_eq!(
   1404             economics.validate().unwrap_err(),
   1405             RadrootsOrderPayloadError::InvalidEconomicTotal {
   1406                 field: "adjustment_total"
   1407             }
   1408         );
   1409 
   1410         let economics = sample_bound_order_economics();
   1411         assert_eq!(
   1412             validate_order_economics_binding(&[], &economics).unwrap_err(),
   1413             RadrootsOrderPayloadError::InvalidOrderEconomicsBinding { field: "items" }
   1414         );
   1415 
   1416         let invalid_order_items = [RadrootsOrderItem {
   1417             bin_id: bin_id("bin-1"),
   1418             bin_count: 0,
   1419         }];
   1420         assert_eq!(
   1421             validate_order_economics_binding(&invalid_order_items, &economics).unwrap_err(),
   1422             RadrootsOrderPayloadError::InvalidOrderEconomicsBinding {
   1423                 field: "items.bin_count"
   1424             }
   1425         );
   1426 
   1427         let duplicate_counts = normalized_order_item_counts(&[
   1428             RadrootsOrderItem {
   1429                 bin_id: bin_id("bin-1"),
   1430                 bin_count: 1,
   1431             },
   1432             RadrootsOrderItem {
   1433                 bin_id: bin_id("bin-1"),
   1434                 bin_count: 2,
   1435             },
   1436         ])
   1437         .unwrap();
   1438         assert_eq!(duplicate_counts[0].bin_count, 3);
   1439 
   1440         assert!(
   1441             normalized_order_item_counts(&[RadrootsOrderItem {
   1442                 bin_id: bin_id("bin-1"),
   1443                 bin_count: 0,
   1444             }])
   1445             .is_none()
   1446         );
   1447         let sorted_counts = normalized_order_item_counts(&[
   1448             RadrootsOrderItem {
   1449                 bin_id: bin_id("bin-b"),
   1450                 bin_count: 1,
   1451             },
   1452             RadrootsOrderItem {
   1453                 bin_id: bin_id("bin-a"),
   1454                 bin_count: 1,
   1455             },
   1456         ])
   1457         .unwrap();
   1458         assert_eq!(sorted_counts[0].bin_id, "bin-a");
   1459     }
   1460 
   1461     #[test]
   1462     fn order_economics_validation_rejects_bad_line_semantics() {
   1463         let mut economics = sample_order_economics();
   1464         economics.discounts[0].effect = RadrootsOrderEconomicEffect::Increase;
   1465         assert_eq!(
   1466             economics.validate().unwrap_err(),
   1467             RadrootsOrderPayloadError::InvalidEconomicLineEffect {
   1468                 field: "discounts",
   1469                 index: 0
   1470             }
   1471         );
   1472 
   1473         let mut economics = sample_order_economics();
   1474         economics.adjustments[0].kind = RadrootsOrderEconomicLineKind::ListingDiscount;
   1475         assert_eq!(
   1476             economics.validate().unwrap_err(),
   1477             RadrootsOrderPayloadError::InvalidEconomicLineKind {
   1478                 field: "adjustments",
   1479                 index: 0
   1480             }
   1481         );
   1482 
   1483         let mut economics = sample_order_economics();
   1484         economics.adjustments[0].amount = usd("0");
   1485         assert_eq!(
   1486             economics.validate().unwrap_err(),
   1487             RadrootsOrderPayloadError::InvalidEconomicLineAmount {
   1488                 field: "adjustments",
   1489                 index: 0
   1490             }
   1491         );
   1492 
   1493         let mut economics = sample_order_economics();
   1494         economics.adjustments[0].amount = usd("-1");
   1495         assert_eq!(
   1496             economics.validate().unwrap_err(),
   1497             RadrootsOrderPayloadError::InvalidEconomicLineAmount {
   1498                 field: "adjustments",
   1499                 index: 0
   1500             }
   1501         );
   1502     }
   1503 
   1504     #[test]
   1505     fn order_economics_helpers_cover_currency_error_paths() {
   1506         assert_eq!(
   1507             validate_total_money(&usd("-1"), RadrootsCoreCurrency::USD, "subtotal").unwrap_err(),
   1508             RadrootsOrderPayloadError::InvalidEconomicTotal { field: "subtotal" }
   1509         );
   1510         assert_eq!(
   1511             validate_total_matches(
   1512                 &usd("1"),
   1513                 &RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::EUR),
   1514                 "total"
   1515             )
   1516             .unwrap_err(),
   1517             RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "total" }
   1518         );
   1519         assert_eq!(
   1520             checked_money_add(
   1521                 &usd("1"),
   1522                 &RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::EUR),
   1523                 "subtotal"
   1524             )
   1525             .unwrap_err(),
   1526             RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "subtotal" }
   1527         );
   1528         assert_eq!(
   1529             checked_money_sub_non_negative(
   1530                 &usd("1"),
   1531                 &RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::EUR),
   1532                 "total"
   1533             )
   1534             .unwrap_err(),
   1535             RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "total" }
   1536         );
   1537     }
   1538 
   1539     #[test]
   1540     fn order_economics_validation_rejects_duplicate_line_ids() {
   1541         let mut economics = sample_order_economics();
   1542         economics.adjustments[1].id = "adjustment-a".into();
   1543         assert_eq!(
   1544             economics.validate().unwrap_err(),
   1545             RadrootsOrderPayloadError::InvalidEconomicOrdering {
   1546                 field: "adjustments"
   1547             }
   1548         );
   1549     }
   1550 
   1551     #[test]
   1552     fn order_economics_validation_rejects_negative_derived_total() {
   1553         let mut economics = sample_order_economics();
   1554         economics.adjustments[1].amount = usd("20");
   1555         economics.adjustment_total = usd("22");
   1556         economics.total = usd("0");
   1557         assert_eq!(
   1558             economics.validate().unwrap_err(),
   1559             RadrootsOrderPayloadError::InvalidEconomicTotal { field: "total" }
   1560         );
   1561     }
   1562 
   1563     #[test]
   1564     fn order_decision_validation_enforces_commitment_invariants() {
   1565         assert_eq!(sample_order_decision().validate(), Ok(()));
   1566 
   1567         let declined = RadrootsOrderDecision {
   1568             decision: RadrootsOrderDecisionOutcome::Declined {
   1569                 reason: "out_of_stock".into(),
   1570             },
   1571             ..sample_order_decision()
   1572         };
   1573         assert_eq!(declined.validate(), Ok(()));
   1574 
   1575         let accepted_without_commitments = RadrootsOrderDecision {
   1576             decision: RadrootsOrderDecisionOutcome::Accepted {
   1577                 inventory_commitments: Vec::new(),
   1578             },
   1579             ..sample_order_decision()
   1580         };
   1581         assert_eq!(
   1582             accepted_without_commitments.validate().unwrap_err(),
   1583             RadrootsOrderPayloadError::MissingInventoryCommitments
   1584         );
   1585 
   1586         let accepted_with_zero_count = RadrootsOrderDecision {
   1587             decision: RadrootsOrderDecisionOutcome::Accepted {
   1588                 inventory_commitments: vec![RadrootsOrderInventoryCommitment {
   1589                     bin_id: bin_id("bin-1"),
   1590                     bin_count: 0,
   1591                 }],
   1592             },
   1593             ..sample_order_decision()
   1594         };
   1595         assert_eq!(
   1596             accepted_with_zero_count.validate().unwrap_err(),
   1597             RadrootsOrderPayloadError::InvalidInventoryCommitmentCount { index: 0 }
   1598         );
   1599 
   1600         let declined_without_reason = RadrootsOrderDecision {
   1601             decision: RadrootsOrderDecisionOutcome::Declined { reason: " ".into() },
   1602             ..sample_order_decision()
   1603         };
   1604         assert_eq!(
   1605             declined_without_reason.validate().unwrap_err(),
   1606             RadrootsOrderPayloadError::EmptyField("reason")
   1607         );
   1608     }
   1609 
   1610     #[test]
   1611     fn order_revision_validation_covers_proposed_and_decision_paths() {
   1612         assert_eq!(sample_order_revision_proposal().validate(), Ok(()));
   1613 
   1614         assert_eq!(
   1615             sample_order_revision_decision(RadrootsOrderRevisionOutcome::Accepted).validate(),
   1616             Ok(())
   1617         );
   1618         assert_eq!(
   1619             sample_order_revision_decision(RadrootsOrderRevisionOutcome::Declined {
   1620                 reason: "out of stock".into(),
   1621             })
   1622             .validate(),
   1623             Ok(())
   1624         );
   1625 
   1626         let declined_without_reason =
   1627             sample_order_revision_decision(RadrootsOrderRevisionOutcome::Declined {
   1628                 reason: " ".into(),
   1629             });
   1630         assert_eq!(
   1631             declined_without_reason.validate().unwrap_err(),
   1632             RadrootsOrderPayloadError::EmptyField("reason")
   1633         );
   1634     }
   1635 
   1636     #[test]
   1637     fn order_cancellation_validation_requires_buyer_bindings_and_reason() {
   1638         assert_eq!(sample_order_cancellation().validate(), Ok(()));
   1639 
   1640         let missing_reason = RadrootsOrderCancellation {
   1641             reason: " ".into(),
   1642             ..sample_order_cancellation()
   1643         };
   1644         assert_eq!(
   1645             missing_reason.validate().unwrap_err(),
   1646             RadrootsOrderPayloadError::EmptyField("reason")
   1647         );
   1648     }
   1649 
   1650     #[test]
   1651     fn order_envelope_serializes_canonical_type_name() {
   1652         let envelope = RadrootsOrderEnvelope::new(
   1653             RadrootsOrderEventType::OrderRequested,
   1654             sample_listing_addr(),
   1655             "order-1",
   1656             sample_order_request(),
   1657         );
   1658         assert_eq!(envelope.validate(), Ok(()));
   1659 
   1660         let json = serde_json::to_value(&envelope).unwrap();
   1661         assert_eq!(json["type"], serde_json::json!("TradeOrderRequested"));
   1662         assert_eq!(json["order_id"], serde_json::json!("order-1"));
   1663         assert_eq!(
   1664             json["listing_addr"],
   1665             serde_json::json!(sample_listing_addr().as_str())
   1666         );
   1667         assert_eq!(json["payload"]["items"][0]["bin_id"], "bin-1");
   1668     }
   1669 
   1670     #[test]
   1671     fn order_envelope_validation_and_display_cover_error_paths() {
   1672         let invalid_version = RadrootsOrderEnvelope {
   1673             version: RADROOTS_ORDER_ENVELOPE_VERSION + 1,
   1674             domain: RadrootsCommercialDomain::Listing,
   1675             message_type: RadrootsOrderEventType::OrderRequested,
   1676             order_id: "order-1".into(),
   1677             listing_addr: sample_listing_addr().into_string(),
   1678             payload: sample_order_request(),
   1679         };
   1680         let invalid_version_err = invalid_version.validate().unwrap_err();
   1681         assert_eq!(
   1682             invalid_version_err,
   1683             RadrootsOrderEnvelopeError::InvalidVersion {
   1684                 expected: RADROOTS_ORDER_ENVELOPE_VERSION,
   1685                 got: RADROOTS_ORDER_ENVELOPE_VERSION + 1,
   1686             }
   1687         );
   1688         assert_eq!(
   1689             invalid_version_err.to_string(),
   1690             "invalid order envelope version: expected 1, got 2"
   1691         );
   1692 
   1693         let missing_order = RadrootsOrderEnvelope::new(
   1694             RadrootsOrderEventType::OrderRequested,
   1695             sample_listing_addr(),
   1696             " ",
   1697             sample_order_request(),
   1698         );
   1699         let missing_order_err = missing_order.validate().unwrap_err();
   1700         assert_eq!(
   1701             missing_order_err,
   1702             RadrootsOrderEnvelopeError::MissingOrderId
   1703         );
   1704         assert_eq!(
   1705             missing_order_err.to_string(),
   1706             "missing order_id for order message"
   1707         );
   1708 
   1709         let missing_listing = RadrootsOrderEnvelope::new(
   1710             RadrootsOrderEventType::OrderRequested,
   1711             " ",
   1712             "order-1",
   1713             sample_order_request(),
   1714         );
   1715         let missing_listing_err = missing_listing.validate().unwrap_err();
   1716         assert_eq!(
   1717             missing_listing_err,
   1718             RadrootsOrderEnvelopeError::MissingListingAddr
   1719         );
   1720         assert_eq!(missing_listing_err.to_string(), "missing listing_addr");
   1721     }
   1722 
   1723     #[test]
   1724     fn listing_parse_error_display_variants() {
   1725         assert_eq!(
   1726             RadrootsListingParseError::InvalidKind(KIND_PROFILE).to_string(),
   1727             "invalid listing kind: 0"
   1728         );
   1729         assert_eq!(
   1730             RadrootsListingParseError::MissingTag("price".into()).to_string(),
   1731             "missing required tag: price"
   1732         );
   1733         assert_eq!(
   1734             RadrootsListingParseError::InvalidTag("farm".into()).to_string(),
   1735             "invalid tag: farm"
   1736         );
   1737         assert_eq!(
   1738             RadrootsListingParseError::InvalidNumber("inventory".into()).to_string(),
   1739             "invalid number: inventory"
   1740         );
   1741         assert_eq!(
   1742             RadrootsListingParseError::InvalidUnit.to_string(),
   1743             "invalid unit"
   1744         );
   1745         assert_eq!(
   1746             RadrootsListingParseError::InvalidCurrency.to_string(),
   1747             "invalid currency"
   1748         );
   1749         assert_eq!(
   1750             RadrootsListingParseError::InvalidJson("bins".into()).to_string(),
   1751             "invalid json: bins"
   1752         );
   1753         assert_eq!(
   1754             RadrootsListingParseError::InvalidDiscount("offer".into()).to_string(),
   1755             "invalid discount data for offer"
   1756         );
   1757     }
   1758 
   1759     #[test]
   1760     fn listing_validation_error_display_variants() {
   1761         assert_eq!(
   1762             (RadrootsTradeValidationListingError::InvalidKind { kind: KIND_PROFILE }).to_string(),
   1763             "invalid listing kind: 0"
   1764         );
   1765         assert_eq!(
   1766             RadrootsTradeValidationListingError::MissingListingId.to_string(),
   1767             "missing listing id"
   1768         );
   1769         assert_eq!(
   1770             RadrootsTradeValidationListingError::ListingEventNotFound {
   1771                 listing_addr: "listing-1".into(),
   1772             }
   1773             .to_string(),
   1774             "listing event not found: listing-1"
   1775         );
   1776         assert_eq!(
   1777             RadrootsTradeValidationListingError::ListingEventFetchFailed {
   1778                 listing_addr: "listing-2".into(),
   1779             }
   1780             .to_string(),
   1781             "listing event fetch failed: listing-2"
   1782         );
   1783         assert_eq!(
   1784             RadrootsTradeValidationListingError::ParseError {
   1785                 error: RadrootsListingParseError::InvalidJson("payload".into()),
   1786             }
   1787             .to_string(),
   1788             "invalid listing data: invalid json: payload"
   1789         );
   1790         assert_eq!(
   1791             RadrootsTradeValidationListingError::InvalidSeller.to_string(),
   1792             "listing author does not match farm pubkey"
   1793         );
   1794         assert_eq!(
   1795             RadrootsTradeValidationListingError::MissingFarmProfile.to_string(),
   1796             "missing farm profile"
   1797         );
   1798         assert_eq!(
   1799             RadrootsTradeValidationListingError::MissingFarmRecord.to_string(),
   1800             "missing farm record"
   1801         );
   1802         assert_eq!(
   1803             RadrootsTradeValidationListingError::MissingTitle.to_string(),
   1804             "missing listing title"
   1805         );
   1806         assert_eq!(
   1807             RadrootsTradeValidationListingError::MissingDescription.to_string(),
   1808             "missing listing description"
   1809         );
   1810         assert_eq!(
   1811             RadrootsTradeValidationListingError::MissingProductType.to_string(),
   1812             "missing listing product type"
   1813         );
   1814         assert_eq!(
   1815             RadrootsTradeValidationListingError::MissingBins.to_string(),
   1816             "missing listing bins"
   1817         );
   1818         assert_eq!(
   1819             RadrootsTradeValidationListingError::MissingPrimaryBin.to_string(),
   1820             "missing primary listing bin"
   1821         );
   1822         assert_eq!(
   1823             RadrootsTradeValidationListingError::InvalidBin.to_string(),
   1824             "invalid listing bin"
   1825         );
   1826         assert_eq!(
   1827             RadrootsTradeValidationListingError::MissingPrice.to_string(),
   1828             "missing listing price"
   1829         );
   1830         assert_eq!(
   1831             RadrootsTradeValidationListingError::InvalidPrice.to_string(),
   1832             "invalid listing price"
   1833         );
   1834         assert_eq!(
   1835             RadrootsTradeValidationListingError::MissingInventory.to_string(),
   1836             "missing listing inventory"
   1837         );
   1838         assert_eq!(
   1839             RadrootsTradeValidationListingError::InvalidInventory.to_string(),
   1840             "invalid listing inventory"
   1841         );
   1842         assert_eq!(
   1843             RadrootsTradeValidationListingError::MissingAvailability.to_string(),
   1844             "missing listing availability"
   1845         );
   1846         assert_eq!(
   1847             RadrootsTradeValidationListingError::MissingLocation.to_string(),
   1848             "missing listing location"
   1849         );
   1850         assert_eq!(
   1851             RadrootsTradeValidationListingError::MissingDeliveryMethod.to_string(),
   1852             "missing listing delivery method"
   1853         );
   1854     }
   1855 
   1856     #[test]
   1857     fn order_payload_error_display_variants_cover_all_messages() {
   1858         let cases = [
   1859             (
   1860                 RadrootsOrderPayloadError::EmptyField("field"),
   1861                 "field cannot be empty",
   1862             ),
   1863             (
   1864                 RadrootsOrderPayloadError::MissingItems,
   1865                 "items must contain at least one item",
   1866             ),
   1867             (
   1868                 RadrootsOrderPayloadError::InvalidItemBinCount { index: 2 },
   1869                 "items[2].bin_count must be greater than zero",
   1870             ),
   1871             (
   1872                 RadrootsOrderPayloadError::MissingEconomicItems,
   1873                 "economics.items must contain at least one item",
   1874             ),
   1875             (
   1876                 RadrootsOrderPayloadError::InvalidEconomicItemBinCount { index: 3 },
   1877                 "economics.items[3].bin_count must be greater than zero",
   1878             ),
   1879             (
   1880                 RadrootsOrderPayloadError::InvalidEconomicItemQuantity { index: 4 },
   1881                 "economics.items[4].quantity_amount must be greater than zero",
   1882             ),
   1883             (
   1884                 RadrootsOrderPayloadError::InvalidEconomicItemPrice { index: 5 },
   1885                 "economics.items[5].unit_price_amount must not be negative",
   1886             ),
   1887             (
   1888                 RadrootsOrderPayloadError::InvalidEconomicItemSubtotal { index: 6 },
   1889                 "economics.items[6].line_subtotal is invalid",
   1890             ),
   1891             (
   1892                 RadrootsOrderPayloadError::InvalidEconomicLineAmount {
   1893                     field: "adjustments",
   1894                     index: 7,
   1895                 },
   1896                 "economics.adjustments[7].amount must be greater than zero",
   1897             ),
   1898             (
   1899                 RadrootsOrderPayloadError::InvalidEconomicLineKind {
   1900                     field: "discounts",
   1901                     index: 8,
   1902                 },
   1903                 "economics.discounts[8].kind is invalid",
   1904             ),
   1905             (
   1906                 RadrootsOrderPayloadError::InvalidEconomicLineEffect {
   1907                     field: "discounts",
   1908                     index: 9,
   1909                 },
   1910                 "economics.discounts[9].effect is invalid",
   1911             ),
   1912             (
   1913                 RadrootsOrderPayloadError::InvalidEconomicCurrency { field: "total" },
   1914                 "economics.total currency is invalid",
   1915             ),
   1916             (
   1917                 RadrootsOrderPayloadError::InvalidEconomicOrdering { field: "items" },
   1918                 "economics.items is not in canonical order",
   1919             ),
   1920             (
   1921                 RadrootsOrderPayloadError::InvalidEconomicTotal { field: "subtotal" },
   1922                 "economics.subtotal total is invalid",
   1923             ),
   1924             (
   1925                 RadrootsOrderPayloadError::InvalidOrderEconomicsBinding { field: "items" },
   1926                 "order items does not match economics",
   1927             ),
   1928             (
   1929                 RadrootsOrderPayloadError::InvalidQuoteVersion,
   1930                 "economics.quote_version must be greater than zero",
   1931             ),
   1932             (
   1933                 RadrootsOrderPayloadError::MissingInventoryCommitments,
   1934                 "accepted decisions must contain at least one inventory commitment",
   1935             ),
   1936             (
   1937                 RadrootsOrderPayloadError::InvalidInventoryCommitmentCount { index: 1 },
   1938                 "inventory_commitments[1].bin_count must be greater than zero",
   1939             ),
   1940         ];
   1941 
   1942         for (error, expected) in cases {
   1943             assert_eq!(error.to_string(), expected);
   1944         }
   1945     }
   1946 }