lib

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

commit 319c978911b90655c3e363b4ac0cd333dfca5cc5
parent 99371fcdc1b622ff062ef622087e8a15a64ebc24
Author: triesap <tyson@radroots.org>
Date:   Sun, 29 Mar 2026 16:54:16 +0000

events: add canonical agricultural trade event ownership

Diffstat:
Mcrates/events-codec/src/lib.rs | 1+
Acrates/events-codec/src/trade/decode.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events-codec/src/trade/encode.rs | 29+++++++++++++++++++++++++++++
Acrates/events-codec/src/trade/mod.rs | 12++++++++++++
Acrates/events-codec/src/trade/tags.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/kinds.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/lib.rs | 1+
Acrates/events/src/trade.rs | 597+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1196 insertions(+), 0 deletions(-)

diff --git a/crates/events-codec/src/lib.rs b/crates/events-codec/src/lib.rs @@ -32,6 +32,7 @@ pub mod seal; pub mod list; pub mod list_set; pub mod listing; +pub mod trade; #[cfg(feature = "serde_json")] pub mod relay_document; diff --git a/crates/events-codec/src/trade/decode.rs b/crates/events-codec/src/trade/decode.rs @@ -0,0 +1,278 @@ +#[cfg(not(feature = "std"))] +use alloc::{format, string::String}; + +#[cfg(feature = "serde_json")] +use radroots_events::{ + RadrootsNostrEvent, + kinds::{KIND_PROFILE, is_trade_listing_kind}, + tags::TAG_D, + trade::{RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeMessageType}, +}; +#[cfg(feature = "serde_json")] +use serde::de::DeserializeOwned; + +#[cfg(feature = "serde_json")] +use crate::d_tag::is_d_tag_base64url; + +#[cfg(feature = "serde_json")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeEnvelopeParseError { + InvalidKind(u32), + InvalidJson, + InvalidEnvelope(RadrootsTradeEnvelopeError), + MessageTypeKindMismatch { + event_kind: u32, + message_type: RadrootsTradeMessageType, + }, + MissingTag(&'static str), + InvalidTag(&'static str), + ListingAddrTagMismatch, + OrderIdTagMismatch, + InvalidListingAddr(RadrootsTradeListingAddressError), +} + +#[cfg(feature = "serde_json")] +impl core::fmt::Display for RadrootsTradeEnvelopeParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidKind(kind) => write!(f, "invalid trade listing event kind: {kind}"), + Self::InvalidJson => write!(f, "invalid trade listing envelope json"), + Self::InvalidEnvelope(error) => write!(f, "{error}"), + Self::MessageTypeKindMismatch { + event_kind, + message_type, + } => write!( + f, + "trade listing envelope type {message_type:?} does not match event kind {event_kind}" + ), + Self::MissingTag(tag) => write!(f, "missing required trade listing tag: {tag}"), + Self::InvalidTag(tag) => write!(f, "invalid trade listing tag: {tag}"), + Self::ListingAddrTagMismatch => { + write!(f, "trade listing address tag does not match envelope") + } + Self::OrderIdTagMismatch => write!(f, "trade order id tag does not match envelope"), + Self::InvalidListingAddr(error) => write!(f, "{error}"), + } + } +} + +#[cfg(all(feature = "std", feature = "serde_json"))] +impl std::error::Error for RadrootsTradeEnvelopeParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidEnvelope(error) => Some(error), + Self::InvalidListingAddr(error) => Some(error), + _ => None, + } + } +} + +#[cfg(feature = "serde_json")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeListingAddress { + pub kind: u32, + pub seller_pubkey: String, + pub listing_id: String, +} + +#[cfg(feature = "serde_json")] +impl RadrootsTradeListingAddress { + pub fn parse(addr: &str) -> Result<Self, RadrootsTradeListingAddressError> { + let (kind_raw, seller_and_listing) = addr + .split_once(':') + .ok_or(RadrootsTradeListingAddressError::InvalidFormat)?; + let (seller_pubkey_raw, listing_id_raw) = seller_and_listing + .split_once(':') + .ok_or(RadrootsTradeListingAddressError::InvalidFormat)?; + if listing_id_raw.contains(':') { + return Err(RadrootsTradeListingAddressError::InvalidFormat); + } + let kind = kind_raw + .parse::<u32>() + .map_err(|_| RadrootsTradeListingAddressError::InvalidFormat)?; + let seller_pubkey = seller_pubkey_raw.to_owned(); + let listing_id = listing_id_raw.to_owned(); + if kind == KIND_PROFILE + || seller_pubkey.trim().is_empty() + || listing_id.trim().is_empty() + || !is_d_tag_base64url(&listing_id) + { + return Err(RadrootsTradeListingAddressError::InvalidFormat); + } + Ok(Self { + kind, + seller_pubkey, + listing_id, + }) + } + + #[inline] + pub fn as_str(&self) -> String { + format!("{}:{}:{}", self.kind, self.seller_pubkey, self.listing_id) + } +} + +#[cfg(feature = "serde_json")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsTradeListingAddressError { + InvalidFormat, +} + +#[cfg(feature = "serde_json")] +impl core::fmt::Display for RadrootsTradeListingAddressError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidFormat => write!(f, "invalid listing address format"), + } + } +} + +#[cfg(all(feature = "std", feature = "serde_json"))] +impl std::error::Error for RadrootsTradeListingAddressError {} + +#[cfg(feature = "serde_json")] +fn required_tag_value<'a>( + tags: &'a [Vec<String>], + key: &'static str, +) -> Result<&'a str, RadrootsTradeEnvelopeParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) + .ok_or(RadrootsTradeEnvelopeParseError::MissingTag(key))?; + let value = tag + .get(1) + .map(|value| value.as_str()) + .ok_or(RadrootsTradeEnvelopeParseError::InvalidTag(key))?; + if value.trim().is_empty() { + return Err(RadrootsTradeEnvelopeParseError::InvalidTag(key)); + } + Ok(value) +} + +#[cfg(feature = "serde_json")] +pub fn trade_envelope_from_event<T: DeserializeOwned>( + event: &RadrootsNostrEvent, +) -> Result<RadrootsTradeEnvelope<T>, RadrootsTradeEnvelopeParseError> { + if !is_trade_listing_kind(event.kind) { + return Err(RadrootsTradeEnvelopeParseError::InvalidKind(event.kind)); + } + let envelope = serde_json::from_str::<RadrootsTradeEnvelope<T>>(&event.content) + .map_err(|_| RadrootsTradeEnvelopeParseError::InvalidJson)?; + envelope + .validate() + .map_err(RadrootsTradeEnvelopeParseError::InvalidEnvelope)?; + if envelope.message_type.kind() != event.kind { + return Err(RadrootsTradeEnvelopeParseError::MessageTypeKindMismatch { + event_kind: event.kind, + message_type: envelope.message_type, + }); + } + + let listing_addr = required_tag_value(&event.tags, "a")?; + if envelope.listing_addr != listing_addr { + return Err(RadrootsTradeEnvelopeParseError::ListingAddrTagMismatch); + } + RadrootsTradeListingAddress::parse(&envelope.listing_addr) + .map_err(RadrootsTradeEnvelopeParseError::InvalidListingAddr)?; + + if let Some(order_id) = envelope.order_id.as_deref() { + let tag_order_id = required_tag_value(&event.tags, TAG_D)?; + if tag_order_id != order_id { + return Err(RadrootsTradeEnvelopeParseError::OrderIdTagMismatch); + } + } + + Ok(envelope) +} + +#[cfg(all(test, feature = "serde_json"))] +mod tests { + use super::{ + RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, + trade_envelope_from_event, + }; + use crate::trade::encode::trade_envelope_event_build; + use radroots_events::{ + RadrootsNostrEvent, + trade::{ + RadrootsTradeEnvelope, RadrootsTradeMessagePayload, RadrootsTradeMessageType, + RadrootsTradeOrder, RadrootsTradeOrderItem, RadrootsTradeOrderStatus, + }, + }; + + fn base_order() -> RadrootsTradeOrder { + RadrootsTradeOrder { + order_id: "order-1".into(), + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + items: vec![RadrootsTradeOrderItem { + bin_id: "lb".into(), + bin_count: 3, + }], + discounts: None, + notes: None, + status: RadrootsTradeOrderStatus::Requested, + } + } + + #[test] + fn listing_address_roundtrips() { + let addr = RadrootsTradeListingAddress::parse("30402:seller:AAAAAAAAAAAAAAAAAAAAAg") + .expect("parse listing address"); + assert_eq!(addr.as_str(), "30402:seller:AAAAAAAAAAAAAAAAAAAAAg"); + } + + #[test] + fn parse_order_request_roundtrip() { + let payload = RadrootsTradeMessagePayload::OrderRequest(base_order()); + let built = trade_envelope_event_build( + "seller", + RadrootsTradeMessageType::OrderRequest, + "30402:seller:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1".into()), + &payload, + ) + .expect("build trade envelope"); + let event = RadrootsNostrEvent { + id: "id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope: RadrootsTradeEnvelope<RadrootsTradeMessagePayload> = + trade_envelope_from_event(&event).expect("parse trade envelope"); + assert_eq!(envelope.message_type, RadrootsTradeMessageType::OrderRequest); + assert_eq!(envelope.order_id.as_deref(), Some("order-1")); + } + + #[test] + fn parse_rejects_listing_addr_mismatch() { + let payload = RadrootsTradeMessagePayload::OrderRequest(base_order()); + let built = trade_envelope_event_build( + "seller", + RadrootsTradeMessageType::OrderRequest, + "30402:seller:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1".into()), + &payload, + ) + .expect("build trade envelope"); + let mut envelope: RadrootsTradeEnvelope<serde_json::Value> = + serde_json::from_str(&built.content).expect("decode json"); + envelope.listing_addr = "30402:seller:BBBBBBBBBBBBBBBBBBBBBg".into(); + let event = RadrootsNostrEvent { + id: "id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: serde_json::to_string(&envelope).expect("encode json"), + sig: "sig".into(), + }; + let err = trade_envelope_from_event::<serde_json::Value>(&event).unwrap_err(); + assert_eq!(err, RadrootsTradeEnvelopeParseError::ListingAddrTagMismatch); + } +} diff --git a/crates/events-codec/src/trade/encode.rs b/crates/events-codec/src/trade/encode.rs @@ -0,0 +1,29 @@ +#[cfg(feature = "serde_json")] +use radroots_events::trade::{RadrootsTradeEnvelope, RadrootsTradeMessageType}; + +#[cfg(feature = "serde_json")] +use crate::{trade::tags::trade_envelope_tags, wire::WireEventParts}; + +#[cfg(feature = "serde_json")] +pub fn trade_envelope_event_build<T: serde::Serialize + Clone>( + recipient_pubkey: impl Into<String>, + message_type: RadrootsTradeMessageType, + listing_addr: impl Into<String>, + order_id: Option<String>, + payload: &T, +) -> Result<WireEventParts, serde_json::Error> { + let listing_addr = listing_addr.into(); + let envelope = RadrootsTradeEnvelope::new( + message_type, + listing_addr.clone(), + order_id.clone(), + payload.clone(), + ); + let content = serde_json::to_string(&envelope)?; + let tags = trade_envelope_tags(recipient_pubkey, &listing_addr, order_id.as_deref()); + Ok(WireEventParts { + kind: message_type.kind(), + content, + tags, + }) +} diff --git a/crates/events-codec/src/trade/mod.rs b/crates/events-codec/src/trade/mod.rs @@ -0,0 +1,12 @@ +pub mod decode; +pub mod encode; +pub mod tags; + +#[cfg(feature = "serde_json")] +pub use decode::{ + RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, + RadrootsTradeListingAddressError, trade_envelope_from_event, +}; +#[cfg(feature = "serde_json")] +pub use encode::trade_envelope_event_build; +pub use tags::{push_trade_chain_tags, trade_envelope_tags, validate_trade_chain}; diff --git a/crates/events-codec/src/trade/tags.rs b/crates/events-codec/src/trade/tags.rs @@ -0,0 +1,137 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}; + +use crate::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 trade_envelope_tags<P, A, D>( + recipient_pubkey: P, + listing_addr: A, + order_id: Option<D>, +) -> Vec<Vec<String>> +where + P: Into<String>, + A: Into<String>, + D: Into<String>, +{ + let mut tags = Vec::with_capacity(2 + usize::from(order_id.is_some())); + push_tag(&mut tags, "p", recipient_pubkey); + push_tag(&mut tags, "a", listing_addr); + if let Some(order_id) = order_id { + push_tag(&mut tags, TAG_D, order_id); + } + tags +} + +#[inline] +pub fn push_trade_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>>, +) { + 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 { + push_tag(tags, TAG_E_PREV, prev); + } + if let Some(d) = trade_id { + push_tag(tags, TAG_D, d); + } +} + +#[inline] +pub fn validate_trade_chain(tags: &[Vec<String>]) -> Result<(), JobParseError> { + 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 { + Err(JobParseError::MissingChainTag(TAG_E_ROOT)) + } else if !has_d { + Err(JobParseError::MissingChainTag(TAG_D)) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ + push_trade_chain_tags, trade_envelope_tags, validate_trade_chain, + }; + use radroots_events::{kinds::KIND_LISTING, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}}; + + #[test] + fn trade_envelope_tags_build_expected_tags() { + let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let tags = trade_envelope_tags("pubkey", &listing_addr, Some("order-1")); + let expected: Vec<Vec<String>> = vec![ + vec![String::from("p"), String::from("pubkey")], + vec![String::from("a"), listing_addr], + vec![String::from(TAG_D), String::from("order-1")], + ]; + assert_eq!(tags, expected); + } + + #[test] + fn push_trade_chain_tags_adds_root_prev_and_trade_id() { + let mut tags = Vec::new(); + push_trade_chain_tags(&mut tags, "root", Some("prev"), Some("trade")); + assert_eq!( + tags, + vec![ + vec![String::from(TAG_E_ROOT), String::from("root")], + vec![String::from(TAG_E_PREV), String::from("prev")], + vec![String::from(TAG_D), String::from("trade")], + ] + ); + } + + #[test] + fn validate_trade_chain_requires_root_and_trade_id() { + let ok = vec![ + vec![String::from(TAG_E_ROOT), String::from("root")], + vec![String::from(TAG_D), String::from("trade")], + ]; + assert!(validate_trade_chain(&ok).is_ok()); + let missing = vec![vec![String::from(TAG_D), String::from("trade")]]; + assert!(validate_trade_chain(&missing).is_err()); + } +} diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -50,6 +50,40 @@ pub const KIND_APP_DATA: u32 = 30078; pub const KIND_LISTING: u32 = 30402; pub const KIND_APPLICATION_HANDLER: u32 = 31990; +pub const KIND_TRADE_LISTING_VALIDATE_REQ: u32 = 5321; +pub const KIND_TRADE_LISTING_VALIDATE_RES: u32 = 6321; +pub const KIND_TRADE_LISTING_ORDER_REQ: u32 = 5322; +pub const KIND_TRADE_LISTING_ORDER_RES: u32 = 6322; +pub const KIND_TRADE_LISTING_ORDER_REVISION_REQ: u32 = 5323; +pub const KIND_TRADE_LISTING_ORDER_REVISION_RES: u32 = 6323; +pub const KIND_TRADE_LISTING_QUESTION_REQ: u32 = 5324; +pub const KIND_TRADE_LISTING_ANSWER_RES: u32 = 6324; +pub const KIND_TRADE_LISTING_DISCOUNT_REQ: u32 = 5325; +pub const KIND_TRADE_LISTING_DISCOUNT_OFFER_RES: u32 = 6325; +pub const KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ: u32 = 5326; +pub const KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ: u32 = 5327; +pub const KIND_TRADE_LISTING_CANCEL_REQ: u32 = 5328; +pub const KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ: u32 = 5329; +pub const KIND_TRADE_LISTING_RECEIPT_REQ: u32 = 5330; + +pub const TRADE_LISTING_KINDS: [u32; 15] = [ + KIND_TRADE_LISTING_VALIDATE_REQ, + KIND_TRADE_LISTING_VALIDATE_RES, + KIND_TRADE_LISTING_ORDER_REQ, + KIND_TRADE_LISTING_ORDER_RES, + KIND_TRADE_LISTING_ORDER_REVISION_REQ, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + KIND_TRADE_LISTING_QUESTION_REQ, + KIND_TRADE_LISTING_ANSWER_RES, + KIND_TRADE_LISTING_DISCOUNT_REQ, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + KIND_TRADE_LISTING_CANCEL_REQ, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + KIND_TRADE_LISTING_RECEIPT_REQ, +]; + pub const KIND_JOB_REQUEST_MIN: u32 = 5000; pub const KIND_JOB_REQUEST_MAX: u32 = 5999; pub const KIND_JOB_RESULT_MIN: u32 = 6000; @@ -57,6 +91,64 @@ pub const KIND_JOB_RESULT_MAX: u32 = 6999; pub const KIND_JOB_FEEDBACK: u32 = 7000; #[inline] +pub const fn is_trade_listing_request_kind(kind: u32) -> bool { + matches!( + kind, + KIND_TRADE_LISTING_VALIDATE_REQ + | KIND_TRADE_LISTING_ORDER_REQ + | KIND_TRADE_LISTING_ORDER_REVISION_REQ + | KIND_TRADE_LISTING_QUESTION_REQ + | KIND_TRADE_LISTING_DISCOUNT_REQ + | KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ + | KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ + | KIND_TRADE_LISTING_CANCEL_REQ + | KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ + | KIND_TRADE_LISTING_RECEIPT_REQ + ) +} + +#[inline] +pub const fn is_trade_listing_result_kind(kind: u32) -> bool { + matches!( + kind, + KIND_TRADE_LISTING_VALIDATE_RES + | KIND_TRADE_LISTING_ORDER_RES + | KIND_TRADE_LISTING_ORDER_REVISION_RES + | KIND_TRADE_LISTING_ANSWER_RES + | KIND_TRADE_LISTING_DISCOUNT_OFFER_RES + ) +} + +#[inline] +pub const fn is_trade_listing_kind(kind: u32) -> bool { + is_trade_listing_request_kind(kind) || is_trade_listing_result_kind(kind) +} + +#[inline] +pub const fn trade_listing_result_kind_for_request(kind: u32) -> Option<u32> { + match kind { + KIND_TRADE_LISTING_VALIDATE_REQ => Some(KIND_TRADE_LISTING_VALIDATE_RES), + KIND_TRADE_LISTING_ORDER_REQ => Some(KIND_TRADE_LISTING_ORDER_RES), + KIND_TRADE_LISTING_ORDER_REVISION_REQ => Some(KIND_TRADE_LISTING_ORDER_REVISION_RES), + KIND_TRADE_LISTING_QUESTION_REQ => Some(KIND_TRADE_LISTING_ANSWER_RES), + KIND_TRADE_LISTING_DISCOUNT_REQ => Some(KIND_TRADE_LISTING_DISCOUNT_OFFER_RES), + _ => None, + } +} + +#[inline] +pub const fn trade_listing_request_kind_for_result(kind: u32) -> Option<u32> { + match kind { + KIND_TRADE_LISTING_VALIDATE_RES => Some(KIND_TRADE_LISTING_VALIDATE_REQ), + KIND_TRADE_LISTING_ORDER_RES => Some(KIND_TRADE_LISTING_ORDER_REQ), + KIND_TRADE_LISTING_ORDER_REVISION_RES => Some(KIND_TRADE_LISTING_ORDER_REVISION_REQ), + KIND_TRADE_LISTING_ANSWER_RES => Some(KIND_TRADE_LISTING_QUESTION_REQ), + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES => Some(KIND_TRADE_LISTING_DISCOUNT_REQ), + _ => None, + } +} + +#[inline] pub const fn is_nip51_standard_list_kind(kind: u32) -> bool { matches!( kind, @@ -215,6 +307,39 @@ mod kinds_constants_tests { ("KIND_APP_DATA", KIND_APP_DATA), ("KIND_LISTING", KIND_LISTING), ("KIND_APPLICATION_HANDLER", KIND_APPLICATION_HANDLER), + ("KIND_TRADE_LISTING_VALIDATE_REQ", KIND_TRADE_LISTING_VALIDATE_REQ), + ("KIND_TRADE_LISTING_VALIDATE_RES", KIND_TRADE_LISTING_VALIDATE_RES), + ("KIND_TRADE_LISTING_ORDER_REQ", KIND_TRADE_LISTING_ORDER_REQ), + ("KIND_TRADE_LISTING_ORDER_RES", KIND_TRADE_LISTING_ORDER_RES), + ( + "KIND_TRADE_LISTING_ORDER_REVISION_REQ", + KIND_TRADE_LISTING_ORDER_REVISION_REQ, + ), + ( + "KIND_TRADE_LISTING_ORDER_REVISION_RES", + KIND_TRADE_LISTING_ORDER_REVISION_RES, + ), + ("KIND_TRADE_LISTING_QUESTION_REQ", KIND_TRADE_LISTING_QUESTION_REQ), + ("KIND_TRADE_LISTING_ANSWER_RES", KIND_TRADE_LISTING_ANSWER_RES), + ("KIND_TRADE_LISTING_DISCOUNT_REQ", KIND_TRADE_LISTING_DISCOUNT_REQ), + ( + "KIND_TRADE_LISTING_DISCOUNT_OFFER_RES", + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + ), + ( + "KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ", + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + ), + ( + "KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ", + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + ), + ("KIND_TRADE_LISTING_CANCEL_REQ", KIND_TRADE_LISTING_CANCEL_REQ), + ( + "KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ", + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + ), + ("KIND_TRADE_LISTING_RECEIPT_REQ", KIND_TRADE_LISTING_RECEIPT_REQ), ("KIND_JOB_REQUEST_MIN", KIND_JOB_REQUEST_MIN), ("KIND_JOB_REQUEST_MAX", KIND_JOB_REQUEST_MAX), ("KIND_JOB_RESULT_MIN", KIND_JOB_RESULT_MIN), @@ -297,4 +422,20 @@ mod kinds_constants_tests { .join("events"); assert_eq!(ts_export_dir_from(None), expected); } + + #[test] + fn classifies_trade_listing_kinds() { + assert!(is_trade_listing_request_kind(KIND_TRADE_LISTING_ORDER_REQ)); + assert!(is_trade_listing_result_kind(KIND_TRADE_LISTING_ORDER_RES)); + assert!(is_trade_listing_kind(KIND_TRADE_LISTING_RECEIPT_REQ)); + assert!(!is_trade_listing_kind(KIND_LISTING)); + assert_eq!( + trade_listing_result_kind_for_request(KIND_TRADE_LISTING_ORDER_REQ), + Some(KIND_TRADE_LISTING_ORDER_RES) + ); + assert_eq!( + trade_listing_request_kind_for_result(KIND_TRADE_LISTING_ORDER_RES), + Some(KIND_TRADE_LISTING_ORDER_REQ) + ); + } } diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -37,6 +37,7 @@ pub mod resource_area; pub mod resource_cap; pub mod seal; pub mod tags; +pub mod trade; #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -0,0 +1,597 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use crate::{RadrootsNostrEventPtr, kinds::*}; +use radroots_core::RadrootsCoreDiscountValue; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +pub const RADROOTS_TRADE_LISTING_DOMAIN: &str = "trade:listing"; +pub const RADROOTS_TRADE_ENVELOPE_VERSION: u16 = 1; + +#[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 enum RadrootsTradeListingParseError { + MissingTag(String), + InvalidTag(String), + InvalidNumber(String), + InvalidUnit, + InvalidCurrency, + InvalidJson(String), + InvalidDiscount(String), +} + +impl core::fmt::Display for RadrootsTradeListingParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MissingTag(tag) => write!(f, "missing required tag: {tag}"), + Self::InvalidTag(tag) => write!(f, "invalid tag: {tag}"), + Self::InvalidNumber(field) => write!(f, "invalid number: {field}"), + Self::InvalidUnit => write!(f, "invalid unit"), + Self::InvalidCurrency => write!(f, "invalid currency"), + Self::InvalidJson(field) => write!(f, "invalid json: {field}"), + Self::InvalidDiscount(kind) => write!(f, "invalid discount data for {kind}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsTradeListingParseError {} + +#[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 = "kind", content = "amount") +)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeListingValidationError { + InvalidKind { kind: u32 }, + MissingListingId, + ListingEventNotFound { listing_addr: String }, + ListingEventFetchFailed { listing_addr: String }, + ParseError { error: RadrootsTradeListingParseError }, + InvalidSeller, + MissingFarmProfile, + MissingFarmRecord, + MissingTitle, + MissingDescription, + MissingProductType, + MissingBins, + MissingPrimaryBin, + InvalidBin, + MissingPrice, + InvalidPrice, + MissingInventory, + InvalidInventory, + MissingAvailability, + MissingLocation, + MissingDeliveryMethod, +} + +impl core::fmt::Display for RadrootsTradeListingValidationError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidKind { kind } => write!(f, "invalid listing kind: {kind}"), + Self::MissingListingId => write!(f, "missing listing id"), + Self::ListingEventNotFound { listing_addr } => { + write!(f, "listing event not found: {listing_addr}") + } + Self::ListingEventFetchFailed { listing_addr } => { + write!(f, "listing event fetch failed: {listing_addr}") + } + Self::ParseError { error } => write!(f, "invalid listing data: {error}"), + Self::InvalidSeller => write!(f, "listing author does not match farm pubkey"), + Self::MissingFarmProfile => write!(f, "missing farm profile"), + Self::MissingFarmRecord => write!(f, "missing farm record"), + Self::MissingTitle => write!(f, "missing listing title"), + Self::MissingDescription => write!(f, "missing listing description"), + Self::MissingProductType => write!(f, "missing listing product type"), + Self::MissingBins => write!(f, "missing listing bins"), + Self::MissingPrimaryBin => write!(f, "missing primary listing bin"), + Self::InvalidBin => write!(f, "invalid listing bin"), + Self::MissingPrice => write!(f, "missing listing price"), + Self::InvalidPrice => write!(f, "invalid listing price"), + Self::MissingInventory => write!(f, "missing listing inventory"), + Self::InvalidInventory => write!(f, "invalid listing inventory"), + Self::MissingAvailability => write!(f, "missing listing availability"), + Self::MissingLocation => write!(f, "missing listing location"), + Self::MissingDeliveryMethod => write!(f, "missing listing delivery method"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsTradeListingValidationError {} + +#[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 RadrootsTradeOrderItem { + 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 = "kind", content = "amount") +)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeOrderChange { + BinCount { item_index: u32, bin_count: u32 }, + ItemAdd { item: RadrootsTradeOrderItem }, + ItemRemove { item_index: 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))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderRevision { + pub revision_id: String, + pub order_id: String, + pub changes: Vec<RadrootsTradeOrderChange>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[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, Debug, PartialEq, Eq)] +pub enum RadrootsTradeOrderStatus { + Draft, + Validated, + Requested, + Questioned, + Revised, + Accepted, + Declined, + Cancelled, + Fulfilled, + Completed, +} + +#[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 RadrootsTradeOrder { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub items: Vec<RadrootsTradeOrderItem>, + #[cfg_attr( + feature = "ts-rs", + ts(optional, type = "RadrootsCoreDiscountValue[] | null") + )] + pub discounts: Option<Vec<RadrootsCoreDiscountValue>>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub notes: Option<String>, + pub status: RadrootsTradeOrderStatus, +} + +#[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, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub order_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub listing_addr: Option<String>, + pub question_text: String, +} + +#[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 RadrootsTradeAnswer { + pub question_id: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub order_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub listing_addr: Option<String>, + pub answer_text: String, +} + +#[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 RadrootsTradeDiscountRequest { + pub discount_id: String, + pub order_id: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] + pub value: RadrootsCoreDiscountValue, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub conditions: Option<String>, +} + +#[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 RadrootsTradeDiscountOffer { + pub discount_id: String, + pub order_id: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] + pub value: RadrootsCoreDiscountValue, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub conditions: Option<String>, +} + +#[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 = "kind", content = "amount") +)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeDiscountDecision { + Accept { + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] + value: RadrootsCoreDiscountValue, + }, + Decline { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + reason: Option<String>, + }, +} + +#[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 = "kind", content = "amount") +)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeFulfillmentStatus { + Preparing, + Shipped, + ReadyForPickup, + Delivered, + Cancelled, +} + +#[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 RadrootsTradeFulfillmentUpdate { + pub status: RadrootsTradeFulfillmentStatus, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub tracking: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub eta: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub notes: Option<String>, +} + +#[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 RadrootsTradeReceipt { + pub acknowledged: bool, + pub at: u64, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub note: Option<String>, +} + +#[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 RadrootsTradeListingValidateRequest { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))] + pub listing_event: Option<RadrootsNostrEventPtr>, +} + +#[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 RadrootsTradeListingValidateResult { + pub valid: bool, + #[cfg_attr( + feature = "ts-rs", + ts(type = "RadrootsTradeListingValidationError[]") + )] + pub errors: Vec<RadrootsTradeListingValidationError>, +} + +#[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 RadrootsTradeOrderResponse { + pub accepted: bool, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[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 RadrootsTradeOrderRevisionResponse { + pub accepted: bool, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[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 RadrootsTradeListingCancel { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +#[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 RadrootsTradeDomain { + #[cfg_attr(feature = "serde", serde(rename = "trade:listing"))] + TradeListing, +} + +#[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 { + ListingValidateRequest, + ListingValidateResult, + OrderRequest, + OrderResponse, + OrderRevision, + OrderRevisionAccept, + OrderRevisionDecline, + Question, + Answer, + DiscountRequest, + DiscountOffer, + DiscountAccept, + DiscountDecline, + Cancel, + FulfillmentUpdate, + Receipt, +} + +impl RadrootsTradeMessageType { + #[inline] + pub const fn from_kind(kind: u32) -> Option<Self> { + match kind { + KIND_TRADE_LISTING_VALIDATE_REQ => Some(Self::ListingValidateRequest), + KIND_TRADE_LISTING_VALIDATE_RES => Some(Self::ListingValidateResult), + KIND_TRADE_LISTING_ORDER_REQ => Some(Self::OrderRequest), + KIND_TRADE_LISTING_ORDER_RES => Some(Self::OrderResponse), + KIND_TRADE_LISTING_ORDER_REVISION_REQ => Some(Self::OrderRevision), + KIND_TRADE_LISTING_ORDER_REVISION_RES => None, + KIND_TRADE_LISTING_QUESTION_REQ => Some(Self::Question), + KIND_TRADE_LISTING_ANSWER_RES => Some(Self::Answer), + KIND_TRADE_LISTING_DISCOUNT_REQ => Some(Self::DiscountRequest), + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES => Some(Self::DiscountOffer), + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ => Some(Self::DiscountAccept), + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ => Some(Self::DiscountDecline), + KIND_TRADE_LISTING_CANCEL_REQ => Some(Self::Cancel), + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ => Some(Self::FulfillmentUpdate), + KIND_TRADE_LISTING_RECEIPT_REQ => Some(Self::Receipt), + _ => None, + } + } + + #[inline] + pub const fn kind(self) -> u32 { + match self { + Self::ListingValidateRequest => KIND_TRADE_LISTING_VALIDATE_REQ, + Self::ListingValidateResult => KIND_TRADE_LISTING_VALIDATE_RES, + Self::OrderRequest => KIND_TRADE_LISTING_ORDER_REQ, + Self::OrderResponse => KIND_TRADE_LISTING_ORDER_RES, + Self::OrderRevision => KIND_TRADE_LISTING_ORDER_REVISION_REQ, + Self::OrderRevisionAccept => KIND_TRADE_LISTING_ORDER_REVISION_RES, + Self::OrderRevisionDecline => KIND_TRADE_LISTING_ORDER_REVISION_RES, + Self::Question => KIND_TRADE_LISTING_QUESTION_REQ, + Self::Answer => KIND_TRADE_LISTING_ANSWER_RES, + Self::DiscountRequest => KIND_TRADE_LISTING_DISCOUNT_REQ, + Self::DiscountOffer => KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + Self::DiscountAccept => KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + Self::DiscountDecline => KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + Self::Cancel => KIND_TRADE_LISTING_CANCEL_REQ, + Self::FulfillmentUpdate => KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + Self::Receipt => KIND_TRADE_LISTING_RECEIPT_REQ, + } + } + + #[inline] + pub const fn requires_order_id(self) -> bool { + !matches!(self, Self::ListingValidateRequest | Self::ListingValidateResult) + } + + #[inline] + pub const fn is_request(self) -> bool { + matches!( + self, + Self::ListingValidateRequest + | Self::OrderRequest + | Self::OrderRevision + | Self::Question + | Self::DiscountRequest + | Self::DiscountAccept + | Self::DiscountDecline + | Self::Cancel + | Self::FulfillmentUpdate + | Self::Receipt + ) + } + + #[inline] + pub const fn is_result(self) -> bool { + !self.is_request() + } +} + +#[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, + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub message_type: RadrootsTradeMessageType, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub order_id: Option<String>, + pub listing_addr: String, + pub payload: T, +} + +impl<T> RadrootsTradeEnvelope<T> { + #[inline] + pub fn new( + message_type: RadrootsTradeMessageType, + listing_addr: impl Into<String>, + order_id: Option<String>, + payload: T, + ) -> Self { + Self { + version: RADROOTS_TRADE_ENVELOPE_VERSION, + domain: RadrootsTradeDomain::TradeListing, + message_type, + order_id, + listing_addr: listing_addr.into(), + payload, + } + } + + pub fn validate(&self) -> Result<(), RadrootsTradeEnvelopeError> { + if self.version != RADROOTS_TRADE_ENVELOPE_VERSION { + return Err(RadrootsTradeEnvelopeError::InvalidVersion { + expected: RADROOTS_TRADE_ENVELOPE_VERSION, + got: self.version, + }); + } + if self.listing_addr.trim().is_empty() { + return Err(RadrootsTradeEnvelopeError::MissingListingAddr); + } + if self.message_type.requires_order_id() { + match self.order_id.as_deref() { + Some(id) if !id.trim().is_empty() => {} + _ => return Err(RadrootsTradeEnvelopeError::MissingOrderId), + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsTradeEnvelopeError { + InvalidVersion { expected: u16, got: u16 }, + MissingOrderId, + MissingListingAddr, +} + +impl core::fmt::Display for RadrootsTradeEnvelopeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidVersion { expected, got } => { + write!(f, "invalid envelope version: expected {expected}, got {got}") + } + Self::MissingOrderId => write!(f, "missing order_id for order-scoped message"), + Self::MissingListingAddr => write!(f, "missing listing_addr"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsTradeEnvelopeError {} + +#[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 = "kind", content = "amount") +)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeMessagePayload { + ListingValidateRequest(RadrootsTradeListingValidateRequest), + ListingValidateResult(RadrootsTradeListingValidateResult), + OrderRequest(RadrootsTradeOrder), + OrderResponse(RadrootsTradeOrderResponse), + OrderRevision(RadrootsTradeOrderRevision), + OrderRevisionAccept(RadrootsTradeOrderRevisionResponse), + OrderRevisionDecline(RadrootsTradeOrderRevisionResponse), + Question(RadrootsTradeQuestion), + Answer(RadrootsTradeAnswer), + DiscountRequest(RadrootsTradeDiscountRequest), + DiscountOffer(RadrootsTradeDiscountOffer), + DiscountAccept(RadrootsTradeDiscountDecision), + DiscountDecline(RadrootsTradeDiscountDecision), + Cancel(RadrootsTradeListingCancel), + FulfillmentUpdate(RadrootsTradeFulfillmentUpdate), + Receipt(RadrootsTradeReceipt), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_type_classifies_request_and_result_kinds() { + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_LISTING_ORDER_REQ), + Some(RadrootsTradeMessageType::OrderRequest) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_LISTING_ORDER_RES), + Some(RadrootsTradeMessageType::OrderResponse) + ); + assert!(RadrootsTradeMessageType::OrderRequest.is_request()); + assert!(RadrootsTradeMessageType::OrderResponse.is_result()); + } + + #[test] + fn envelope_requires_order_id_for_order_scoped_messages() { + let envelope = RadrootsTradeEnvelope::new( + RadrootsTradeMessageType::OrderRequest, + "30402:pubkey:AAAAAAAAAAAAAAAAAAAAAg", + None, + RadrootsTradeMessagePayload::OrderRequest(RadrootsTradeOrder { + order_id: "order-1".into(), + listing_addr: "30402:pubkey:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + items: vec![], + discounts: None, + notes: None, + status: RadrootsTradeOrderStatus::Requested, + }), + ); + assert_eq!( + envelope.validate().unwrap_err(), + RadrootsTradeEnvelopeError::MissingOrderId + ); + } +}