lib

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

commit de11b3829f43a0e0ab710b329631e973060d8260
parent 595ada4b744ac27843108c08860eed6ee4df284b
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 21:45:30 +0000

events: add active trade payload models

Diffstat:
Mcrates/events/src/trade.rs | 445+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 445 insertions(+), 0 deletions(-)

diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -190,6 +190,84 @@ pub struct RadrootsTradeOrder { #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderRequested { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub items: Vec<RadrootsTradeOrderItem>, +} + +impl RadrootsTradeOrderRequested { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + validate_required_field(&self.order_id, "order_id")?; + validate_required_field(&self.listing_addr, "listing_addr")?; + validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; + validate_required_field(&self.seller_pubkey, "seller_pubkey")?; + validate_order_items(&self.items) + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeInventoryCommitment { + pub bin_id: String, + pub bin_count: u32, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeOrderDecision { + Accepted { + inventory_commitments: Vec<RadrootsTradeInventoryCommitment>, + }, + Declined { + reason: String, + }, +} + +impl RadrootsTradeOrderDecision { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + match self { + Self::Accepted { + inventory_commitments, + } => validate_inventory_commitments(inventory_commitments), + Self::Declined { reason } => validate_required_field(reason, "reason"), + } + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderDecisionEvent { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub decision: RadrootsTradeOrderDecision, +} + +impl RadrootsTradeOrderDecisionEvent { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + validate_required_field(&self.order_id, "order_id")?; + validate_required_field(&self.listing_addr, "listing_addr")?; + validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; + validate_required_field(&self.seller_pubkey, "seller_pubkey")?; + self.decision.validate() + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeQuestion { pub question_id: String, } @@ -345,6 +423,54 @@ pub enum RadrootsTradeTransportLane { #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsActiveTradeMessageType { + #[cfg_attr(feature = "serde", serde(rename = "TradeOrderRequested"))] + TradeOrderRequested, + #[cfg_attr(feature = "serde", serde(rename = "TradeOrderDecision"))] + TradeOrderDecision, +} + +impl RadrootsActiveTradeMessageType { + #[inline] + pub const fn from_kind(kind: u32) -> Option<Self> { + match kind { + KIND_TRADE_ORDER_REQUEST => Some(Self::TradeOrderRequested), + KIND_TRADE_ORDER_DECISION => Some(Self::TradeOrderDecision), + _ => None, + } + } + + #[inline] + pub const fn kind(self) -> u32 { + match self { + Self::TradeOrderRequested => KIND_TRADE_ORDER_REQUEST, + Self::TradeOrderDecision => KIND_TRADE_ORDER_DECISION, + } + } + + #[inline] + pub const fn name(self) -> &'static str { + match self { + Self::TradeOrderRequested => "TradeOrderRequested", + Self::TradeOrderDecision => "TradeOrderDecision", + } + } + + #[inline] + pub const fn requires_listing_snapshot(self) -> bool { + matches!(self, Self::TradeOrderRequested) + } + + #[inline] + pub const fn requires_trade_chain(self) -> bool { + matches!(self, Self::TradeOrderDecision) + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsTradeMessageType { @@ -492,6 +618,80 @@ impl RadrootsTradeMessageType { #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveTradeEnvelope<T> { + pub version: u16, + pub domain: RadrootsTradeDomain, + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub message_type: RadrootsActiveTradeMessageType, + pub order_id: String, + pub listing_addr: String, + pub payload: T, +} + +impl<T> RadrootsActiveTradeEnvelope<T> { + #[inline] + pub fn new( + message_type: RadrootsActiveTradeMessageType, + listing_addr: impl Into<String>, + order_id: impl Into<String>, + payload: T, + ) -> Self { + Self { + version: RADROOTS_TRADE_ENVELOPE_VERSION, + domain: RadrootsTradeDomain::TradeListing, + message_type, + order_id: order_id.into(), + listing_addr: listing_addr.into(), + payload, + } + } + + pub fn validate(&self) -> Result<(), RadrootsActiveTradeEnvelopeError> { + if self.version != RADROOTS_TRADE_ENVELOPE_VERSION { + return Err(RadrootsActiveTradeEnvelopeError::InvalidVersion { + expected: RADROOTS_TRADE_ENVELOPE_VERSION, + got: self.version, + }); + } + if self.order_id.trim().is_empty() { + return Err(RadrootsActiveTradeEnvelopeError::MissingOrderId); + } + if self.listing_addr.trim().is_empty() { + return Err(RadrootsActiveTradeEnvelopeError::MissingListingAddr); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsActiveTradeEnvelopeError { + InvalidVersion { expected: u16, got: u16 }, + MissingOrderId, + MissingListingAddr, +} + +impl core::fmt::Display for RadrootsActiveTradeEnvelopeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidVersion { expected, got } => { + write!( + f, + "invalid active trade envelope version: expected {expected}, got {got}" + ) + } + Self::MissingOrderId => write!(f, "missing order_id for active trade message"), + Self::MissingListingAddr => write!(f, "missing listing_addr"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsActiveTradeEnvelopeError {} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeEnvelope<T> { pub version: u16, pub domain: RadrootsTradeDomain, @@ -542,6 +742,81 @@ impl<T> RadrootsTradeEnvelope<T> { } #[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsActiveTradePayloadError { + EmptyField(&'static str), + MissingItems, + InvalidItemBinCount { index: usize }, + MissingInventoryCommitments, + InvalidInventoryCommitmentCount { index: usize }, +} + +impl core::fmt::Display for RadrootsActiveTradePayloadError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::EmptyField(field) => write!(f, "{field} cannot be empty"), + Self::MissingItems => write!(f, "items must contain at least one item"), + Self::InvalidItemBinCount { index } => { + write!(f, "items[{index}].bin_count must be greater than zero") + } + Self::MissingInventoryCommitments => { + write!( + f, + "accepted decisions must contain at least one inventory commitment" + ) + } + Self::InvalidInventoryCommitmentCount { index } => write!( + f, + "inventory_commitments[{index}].bin_count must be greater than zero" + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsActiveTradePayloadError {} + +fn validate_required_field( + value: &str, + field: &'static str, +) -> Result<(), RadrootsActiveTradePayloadError> { + if value.trim().is_empty() { + Err(RadrootsActiveTradePayloadError::EmptyField(field)) + } else { + Ok(()) + } +} + +fn validate_order_items( + items: &[RadrootsTradeOrderItem], +) -> Result<(), RadrootsActiveTradePayloadError> { + if items.is_empty() { + return Err(RadrootsActiveTradePayloadError::MissingItems); + } + for (index, item) in items.iter().enumerate() { + validate_required_field(&item.bin_id, "bin_id")?; + if item.bin_count == 0 { + return Err(RadrootsActiveTradePayloadError::InvalidItemBinCount { index }); + } + } + Ok(()) +} + +fn validate_inventory_commitments( + commitments: &[RadrootsTradeInventoryCommitment], +) -> Result<(), RadrootsActiveTradePayloadError> { + if commitments.is_empty() { + return Err(RadrootsActiveTradePayloadError::MissingInventoryCommitments); + } + for (index, commitment) in commitments.iter().enumerate() { + validate_required_field(&commitment.bin_id, "bin_id")?; + if commitment.bin_count == 0 { + return Err(RadrootsActiveTradePayloadError::InvalidInventoryCommitmentCount { index }); + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum RadrootsTradeEnvelopeError { InvalidVersion { expected: u16, got: u16 }, MissingOrderId, @@ -656,6 +931,38 @@ mod tests { } } + fn sample_active_order_request() -> RadrootsTradeOrderRequested { + RadrootsTradeOrderRequested { + order_id: "order-1".into(), + listing_addr: sample_listing_addr(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".into(), + bin_count: 2, + }], + } + } + + fn sample_inventory_commitment() -> RadrootsTradeInventoryCommitment { + RadrootsTradeInventoryCommitment { + bin_id: "bin-1".into(), + bin_count: 2, + } + } + + fn sample_active_order_decision() -> RadrootsTradeOrderDecisionEvent { + RadrootsTradeOrderDecisionEvent { + order_id: "order-1".into(), + listing_addr: sample_listing_addr(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + decision: RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![sample_inventory_commitment()], + }, + } + } + fn sample_order_revision() -> RadrootsTradeOrderRevision { RadrootsTradeOrderRevision { revision_id: "rev-1".into(), @@ -722,6 +1029,144 @@ mod tests { } #[test] + fn active_message_type_uses_canonical_names_and_kinds() { + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_ORDER_REQUEST), + Some(RadrootsActiveTradeMessageType::TradeOrderRequested) + ); + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_ORDER_DECISION), + Some(RadrootsActiveTradeMessageType::TradeOrderDecision) + ); + assert_eq!(RadrootsActiveTradeMessageType::from_kind(3431), None); + assert_eq!( + RadrootsActiveTradeMessageType::TradeOrderRequested.kind(), + KIND_TRADE_ORDER_REQUEST + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeOrderDecision.kind(), + KIND_TRADE_ORDER_DECISION + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeOrderRequested.name(), + "TradeOrderRequested" + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeOrderDecision.name(), + "TradeOrderDecision" + ); + assert!(RadrootsActiveTradeMessageType::TradeOrderRequested.requires_listing_snapshot()); + assert!(RadrootsActiveTradeMessageType::TradeOrderDecision.requires_trade_chain()); + + let request_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderRequested).unwrap(); + let decision_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderDecision).unwrap(); + assert_eq!(request_name, serde_json::json!("TradeOrderRequested")); + assert_eq!(decision_name, serde_json::json!("TradeOrderDecision")); + } + + #[test] + fn active_order_request_validation_rejects_invalid_fields() { + assert_eq!(sample_active_order_request().validate(), Ok(())); + + let mut missing_order_id = sample_active_order_request(); + missing_order_id.order_id = " ".into(); + assert_eq!( + missing_order_id.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("order_id") + ); + + let mut missing_items = sample_active_order_request(); + missing_items.items.clear(); + assert_eq!( + missing_items.validate().unwrap_err(), + RadrootsActiveTradePayloadError::MissingItems + ); + + let mut invalid_count = sample_active_order_request(); + invalid_count.items[0].bin_count = 0; + assert_eq!( + invalid_count.validate().unwrap_err(), + RadrootsActiveTradePayloadError::InvalidItemBinCount { index: 0 } + ); + + let mut missing_bin_id = sample_active_order_request(); + missing_bin_id.items[0].bin_id = " ".into(); + assert_eq!( + missing_bin_id.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("bin_id") + ); + } + + #[test] + fn active_order_decision_validation_enforces_commitment_invariants() { + assert_eq!(sample_active_order_decision().validate(), Ok(())); + + let declined = RadrootsTradeOrderDecisionEvent { + decision: RadrootsTradeOrderDecision::Declined { + reason: "out_of_stock".into(), + }, + ..sample_active_order_decision() + }; + assert_eq!(declined.validate(), Ok(())); + + let accepted_without_commitments = RadrootsTradeOrderDecisionEvent { + decision: RadrootsTradeOrderDecision::Accepted { + inventory_commitments: Vec::new(), + }, + ..sample_active_order_decision() + }; + assert_eq!( + accepted_without_commitments.validate().unwrap_err(), + RadrootsActiveTradePayloadError::MissingInventoryCommitments + ); + + let accepted_with_zero_count = RadrootsTradeOrderDecisionEvent { + decision: RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".into(), + bin_count: 0, + }], + }, + ..sample_active_order_decision() + }; + assert_eq!( + accepted_with_zero_count.validate().unwrap_err(), + RadrootsActiveTradePayloadError::InvalidInventoryCommitmentCount { index: 0 } + ); + + let declined_without_reason = RadrootsTradeOrderDecisionEvent { + decision: RadrootsTradeOrderDecision::Declined { reason: " ".into() }, + ..sample_active_order_decision() + }; + assert_eq!( + declined_without_reason.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("reason") + ); + } + + #[test] + fn active_envelope_serializes_canonical_type_name() { + let envelope = RadrootsActiveTradeEnvelope::new( + RadrootsActiveTradeMessageType::TradeOrderRequested, + sample_listing_addr(), + "order-1", + sample_active_order_request(), + ); + assert_eq!(envelope.validate(), Ok(())); + + let json = serde_json::to_value(&envelope).unwrap(); + assert_eq!(json["type"], serde_json::json!("TradeOrderRequested")); + assert_eq!(json["order_id"], serde_json::json!("order-1")); + assert_eq!( + json["listing_addr"], + serde_json::json!("30402:pubkey:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!(json["payload"]["items"][0]["bin_id"], "bin-1"); + } + + #[test] fn listing_parse_error_display_variants() { assert_eq!( RadrootsTradeListingParseError::InvalidKind(KIND_PROFILE).to_string(),