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:
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
+ );
+ }
+}