lib

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

commit 98f327eaebc17e9adf4ddb1a578c9cfd6ae60662
parent a5e917fc0b76e524ea6b7c77f0e6cca437a50a18
Author: triesap <tyson@radroots.org>
Date:   Mon, 22 Dec 2025 17:09:41 +0000

trade: tighten listing parsing and pricing APIs


- gate typeshare/serde attributes for trade listing models and stages
- add explicit TradeListingStage parse error with tests
- harden chain tag validation and reduce tag allocations
- add try_* pricing helpers for explicit errors and unit-mismatch test

Diffstat:
Mevents/bindings/ts/src/types.ts | 2--
Mtrade/src/listing/kinds.rs | 36++++++++++++++++++------------------
Mtrade/src/listing/meta.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtrade/src/listing/model.rs | 7++-----
Mtrade/src/listing/price_ext.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtrade/src/listing/stage/accept.rs | 4++--
Mtrade/src/listing/stage/conveyance.rs | 13+++++++------
Mtrade/src/listing/stage/fulfillment.rs | 6+++---
Mtrade/src/listing/stage/invoice.rs | 4++--
Mtrade/src/listing/stage/order.rs | 8++++----
Mtrade/src/listing/stage/payment.rs | 13+++++++------
Mtrade/src/listing/stage/receipt.rs | 4++--
Mtrade/src/listing/tags.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
13 files changed, 294 insertions(+), 75 deletions(-)

diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts @@ -1,5 +1,3 @@ -import type { RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice } from "@radroots/core-bindings"; - // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type JobFeedbackStatus = "payment_required" | "processing" | "error" | "success" | "partial"; diff --git a/trade/src/listing/kinds.rs b/trade/src/listing/kinds.rs @@ -1,46 +1,46 @@ -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_ORDER_REQ: u16 = 5301; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_ORDER_RES: u16 = 6301; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_ACCEPT_REQ: u16 = 5302; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_ACCEPT_RES: u16 = 6302; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_CONVEYANCE_REQ: u16 = 5303; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_CONVEYANCE_RES: u16 = 6303; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_INVOICE_REQ: u16 = 5304; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_INVOICE_RES: u16 = 6304; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_PAYMENT_REQ: u16 = 5305; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_PAYMENT_RES: u16 = 6305; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_FULFILL_REQ: u16 = 5306; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_FULFILL_RES: u16 = 6306; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_RECEIPT_REQ: u16 = 5307; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_RECEIPT_RES: u16 = 6307; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_CANCEL_REQ: u16 = 5309; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_CANCEL_RES: u16 = 6309; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_REFUND_REQ: u16 = 5310; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_TRADE_LISTING_REFUND_RES: u16 = 6310; #[inline] diff --git a/trade/src/listing/meta.rs b/trade/src/listing/meta.rs @@ -10,7 +10,7 @@ pub const MARKER_INVOICE_RESULT: &str = "invoice_result"; pub const MARKER_FULFILLMENT_RESULT: &str = "fulfillment_result"; pub const MARKER_PROOF: &str = "proof"; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr( @@ -29,9 +29,10 @@ pub enum TradeListingStage { Refund, } -impl fmt::Display for TradeListingStage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { +impl TradeListingStage { + #[inline] + pub const fn as_str(&self) -> &'static str { + match self { TradeListingStage::Order => "order", TradeListingStage::Accept => "accept", TradeListingStage::Conveyance => "conveyance", @@ -41,12 +42,37 @@ impl fmt::Display for TradeListingStage { TradeListingStage::Receipt => "receipt", TradeListingStage::Cancel => "cancel", TradeListingStage::Refund => "refund", - }) + } + } +} + +impl fmt::Display for TradeListingStage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TradeListingStageParseError { + UnknownStage, +} + +impl fmt::Display for TradeListingStageParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TradeListingStageParseError::UnknownStage => { + write!(f, "unknown trade listing stage") + } + } } } +#[cfg(feature = "std")] +impl std::error::Error for TradeListingStageParseError {} + impl FromStr for TradeListingStage { - type Err = (); + type Err = TradeListingStageParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "order" => Ok(Self::Order), @@ -58,7 +84,39 @@ impl FromStr for TradeListingStage { "receipt" => Ok(Self::Receipt), "cancel" => Ok(Self::Cancel), "refund" => Ok(Self::Refund), - _ => Err(()), + _ => Err(TradeListingStageParseError::UnknownStage), + } + } +} + +#[cfg(test)] +mod tests { + use super::{TradeListingStage, TradeListingStageParseError}; + + #[test] + fn stage_roundtrip() { + let cases = [ + (TradeListingStage::Order, "order"), + (TradeListingStage::Accept, "accept"), + (TradeListingStage::Conveyance, "conveyance"), + (TradeListingStage::Invoice, "invoice"), + (TradeListingStage::Payment, "payment"), + (TradeListingStage::Fulfillment, "fulfillment"), + (TradeListingStage::Receipt, "receipt"), + (TradeListingStage::Cancel, "cancel"), + (TradeListingStage::Refund, "refund"), + ]; + + for (stage, name) in cases { + assert_eq!(stage.as_str(), name); + assert_eq!(stage.to_string(), name); + assert_eq!(name.parse::<TradeListingStage>().unwrap(), stage); } } + + #[test] + fn stage_parse_rejects_unknown() { + let err = "unknown".parse::<TradeListingStage>().unwrap_err(); + assert_eq!(err, TradeListingStageParseError::UnknownStage); + } } diff --git a/trade/src/listing/model.rs b/trade/src/listing/model.rs @@ -1,7 +1,4 @@ -#[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; - -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct RadrootsTradeListingSubtotal { @@ -11,7 +8,7 @@ pub struct RadrootsTradeListingSubtotal { pub quantity_unit: radroots_core::RadrootsCoreUnit, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct RadrootsTradeListingTotal { diff --git a/trade/src/listing/price_ext.rs b/trade/src/listing/price_ext.rs @@ -1,7 +1,7 @@ use crate::listing::model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal}; use radroots_core::{ RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, - RadrootsCoreQuantityPriceOps, + RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, }; use radroots_events::listing::RadrootsListingQuantity; @@ -14,19 +14,33 @@ pub trait ListingPricingExt { fn total_for(&self, qty: &RadrootsListingQuantity) -> RadrootsTradeListingTotal; } +pub trait ListingPricingTryExt { + fn try_subtotal_for( + &self, + qty: &RadrootsListingQuantity, + ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError>; + fn try_total_for( + &self, + qty: &RadrootsListingQuantity, + ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError>; +} + +#[inline] +fn effective_quantity(qty: &RadrootsListingQuantity) -> RadrootsCoreQuantity { + let count = qty.count.unwrap_or(1); + let amount = qty.value.amount * RadrootsCoreDecimal::from(count); + RadrootsCoreQuantity::new(amount, qty.value.unit) +} + impl ListingPricingExt for RadrootsCoreQuantityPrice { fn subtotal_for(&self, qty: &RadrootsListingQuantity) -> RadrootsTradeListingSubtotal { - let count = qty.count.unwrap_or(1); - let effective_qty = RadrootsCoreQuantity::new( - qty.value.amount * RadrootsCoreDecimal::from(count as u32), - qty.value.unit, - ); - + let effective_qty = effective_quantity(qty); let money = self.cost_for_rounded(&effective_qty); + let currency = money.currency; RadrootsTradeListingSubtotal { - price_amount: money.clone(), - price_currency: self.amount.currency, + price_amount: money, + price_currency: currency, quantity_amount: effective_qty.amount, quantity_unit: effective_qty.unit, } @@ -42,3 +56,70 @@ impl ListingPricingExt for RadrootsCoreQuantityPrice { } } } + +impl ListingPricingTryExt for RadrootsCoreQuantityPrice { + fn try_subtotal_for( + &self, + qty: &RadrootsListingQuantity, + ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError> { + let effective_qty = effective_quantity(qty); + let money = self.try_cost_for_rounded(&effective_qty)?; + let currency = money.currency; + + Ok(RadrootsTradeListingSubtotal { + price_amount: money, + price_currency: currency, + quantity_amount: effective_qty.amount, + quantity_unit: effective_qty.unit, + }) + } + + fn try_total_for( + &self, + qty: &RadrootsListingQuantity, + ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError> { + let sub = self.try_subtotal_for(qty)?; + Ok(RadrootsTradeListingTotal { + price_amount: sub.price_amount, + price_currency: sub.price_currency, + quantity_amount: sub.quantity_amount, + quantity_unit: sub.quantity_unit, + }) + } +} + +#[cfg(test)] +mod tests { + use super::ListingPricingTryExt; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit, + }; + use radroots_events::listing::RadrootsListingQuantity; + + #[test] + fn try_subtotal_for_rejects_unit_mismatch() { + let price = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), + ); + + let qty = RadrootsListingQuantity { + value: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + label: None, + count: None, + }; + + let err = price.try_subtotal_for(&qty).unwrap_err(); + assert_eq!( + err, + RadrootsCoreQuantityPriceError::UnitMismatch { + have: RadrootsCoreUnit::MassG, + want: RadrootsCoreUnit::Each, + } + ); + } +} diff --git a/trade/src/listing/stage/accept.rs b/trade/src/listing/stage/accept.rs @@ -1,7 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingAcceptRequest { @@ -9,7 +9,7 @@ pub struct TradeListingAcceptRequest { pub listing_event_id: String, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingAcceptResult { diff --git a/trade/src/listing/stage/conveyance.rs b/trade/src/listing/stage/conveyance.rs @@ -1,12 +1,13 @@ -#![cfg_attr(not(feature = "serde"), allow(unused_attributes))] - #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] -#[serde(rename_all = "snake_case", tag = "kind", content = "amount")] +#[cfg_attr( + feature = "serde", + serde(rename_all = "snake_case", tag = "kind", content = "amount") +)] pub enum TradeListingConveyanceMethod { SellerDelivery { window: Option<String>, @@ -23,7 +24,7 @@ pub enum TradeListingConveyanceMethod { }, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingConveyanceRequest { @@ -31,7 +32,7 @@ pub struct TradeListingConveyanceRequest { pub method: TradeListingConveyanceMethod, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingConveyanceResult { diff --git a/trade/src/listing/stage/fulfillment.rs b/trade/src/listing/stage/fulfillment.rs @@ -1,14 +1,14 @@ #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingFulfillmentRequest { pub payment_result_event_id: String, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] #[cfg_attr( @@ -23,7 +23,7 @@ pub enum TradeListingFulfillmentState { Canceled, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingFulfillmentResult { diff --git a/trade/src/listing/stage/invoice.rs b/trade/src/listing/stage/invoice.rs @@ -1,14 +1,14 @@ #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingInvoiceRequest { pub accept_result_event_id: String, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingInvoiceResult { diff --git a/trade/src/listing/stage/order.rs b/trade/src/listing/stage/order.rs @@ -1,11 +1,11 @@ #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::vec::Vec; use radroots_core::RadrootsCoreQuantityPrice; use radroots_events::listing::{RadrootsListingDiscount, RadrootsListingQuantity}; use crate::listing::model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal}; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingOrderRequestPayload { @@ -13,7 +13,7 @@ pub struct TradeListingOrderRequestPayload { pub quantity: RadrootsListingQuantity, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingOrderRequest { @@ -21,7 +21,7 @@ pub struct TradeListingOrderRequest { pub payload: TradeListingOrderRequestPayload, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingOrderResult { diff --git a/trade/src/listing/stage/payment.rs b/trade/src/listing/stage/payment.rs @@ -1,12 +1,13 @@ -#![cfg_attr(not(feature = "serde"), allow(unused_attributes))] - #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] -#[serde(rename_all = "snake_case", tag = "kind", content = "amount")] +#[cfg_attr( + feature = "serde", + serde(rename_all = "snake_case", tag = "kind", content = "amount") +)] pub enum TradeListingPaymentProof { ZapEvent { id: String }, Preimage { hex: String }, @@ -14,7 +15,7 @@ pub enum TradeListingPaymentProof { ExternalRef { provider: String, ref_id: String }, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingPaymentProofRequest { @@ -22,7 +23,7 @@ pub struct TradeListingPaymentProofRequest { pub proof: TradeListingPaymentProof, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingPaymentResult { diff --git a/trade/src/listing/stage/receipt.rs b/trade/src/listing/stage/receipt.rs @@ -1,7 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingReceiptRequest { @@ -9,7 +9,7 @@ pub struct TradeListingReceiptRequest { pub note: Option<String>, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingReceiptResult { diff --git a/trade/src/listing/tags.rs b/trade/src/listing/tags.rs @@ -5,34 +5,117 @@ use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}; use radroots_events_codec::job::error::JobParseError; #[inline] +fn push_tag(tags: &mut Vec<Vec<String>>, name: &'static str, value: impl Into<String>) { + let mut tag = Vec::with_capacity(2); + tag.push(name.to_owned()); + tag.push(value.into()); + tags.push(tag); +} + +#[inline] pub fn push_trade_listing_chain_tags( tags: &mut Vec<Vec<String>>, e_root_id: impl Into<String>, e_prev_id: Option<impl Into<String>>, trade_id: Option<impl Into<String>>, ) { - tags.push(vec![TAG_E_ROOT.into(), e_root_id.into()]); + let mut reserve = 1; + if e_prev_id.is_some() { + reserve += 1; + } + if trade_id.is_some() { + reserve += 1; + } + tags.reserve(reserve); + + push_tag(tags, TAG_E_ROOT, e_root_id); if let Some(prev) = e_prev_id { - tags.push(vec![TAG_E_PREV.into(), prev.into()]); + push_tag(tags, TAG_E_PREV, prev); } if let Some(d) = trade_id { - tags.push(vec![TAG_D.into(), d.into()]); + push_tag(tags, TAG_D, d); } } #[inline] pub fn validate_trade_listing_chain(tags: &[Vec<String>]) -> Result<(), JobParseError> { - let has_root = tags - .iter() - .any(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_E_ROOT)); + let mut has_root = false; + let mut has_d = false; + + for tag in tags { + match tag.as_slice() { + [key, value, ..] if key == TAG_E_ROOT => { + if value.trim().is_empty() { + return Err(JobParseError::InvalidTag(TAG_E_ROOT)); + } + has_root = true; + } + [key] if key == TAG_E_ROOT => { + return Err(JobParseError::InvalidTag(TAG_E_ROOT)); + } + [key, value, ..] if key == TAG_D => { + if value.trim().is_empty() { + return Err(JobParseError::InvalidTag(TAG_D)); + } + has_d = true; + } + [key] if key == TAG_D => { + return Err(JobParseError::InvalidTag(TAG_D)); + } + _ => {} + } + + if has_root && has_d { + return Ok(()); + } + } + if !has_root { return Err(JobParseError::MissingChainTag(TAG_E_ROOT)); } - let has_d = tags - .iter() - .any(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_D)); if !has_d { return Err(JobParseError::MissingChainTag(TAG_D)); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::validate_trade_listing_chain; + use radroots_events::tags::{TAG_D, TAG_E_ROOT}; + use radroots_events_codec::job::error::JobParseError; + + #[test] + fn validate_trade_listing_chain_ok() { + let tags = vec![ + vec![TAG_E_ROOT.into(), "root".into()], + vec![TAG_D.into(), "trade".into()], + ]; + assert!(validate_trade_listing_chain(&tags).is_ok()); + } + + #[test] + fn validate_trade_listing_chain_rejects_missing_root() { + let tags = vec![vec![TAG_D.into(), "trade".into()]]; + match validate_trade_listing_chain(&tags) { + Err(JobParseError::MissingChainTag(tag)) => { + assert_eq!(tag, TAG_E_ROOT); + } + other => panic!("expected missing root tag, got {other:?}"), + } + } + + #[test] + fn validate_trade_listing_chain_rejects_empty_root_value() { + let tags = vec![ + vec![TAG_E_ROOT.into(), " ".into()], + vec![TAG_D.into(), "trade".into()], + ]; + match validate_trade_listing_chain(&tags) { + Err(JobParseError::InvalidTag(tag)) => { + assert_eq!(tag, TAG_E_ROOT); + } + other => panic!("expected invalid root tag, got {other:?}"), + } + } +}