commit 4801bd8e6392fe036bc4cfe40621045d34279918
parent da89a3dac1107db9b81fd749db912379e989d37f
Author: triesap <tyson@radroots.org>
Date: Thu, 2 Apr 2026 18:51:40 +0000
trade: enforce public snapshot and thread invariants
Diffstat:
10 files changed, 1358 insertions(+), 340 deletions(-)
diff --git a/crates/events-codec/src/trade/decode.rs b/crates/events-codec/src/trade/decode.rs
@@ -3,9 +3,9 @@ use alloc::{borrow::ToOwned, format, string::String, vec::Vec};
#[cfg(feature = "serde_json")]
use radroots_events::{
- RadrootsNostrEvent,
+ RadrootsNostrEvent, RadrootsNostrEventPtr,
kinds::{KIND_PROFILE, is_trade_kind},
- tags::TAG_D,
+ tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT},
trade::{RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeMessageType},
};
#[cfg(feature = "serde_json")]
@@ -13,6 +13,11 @@ use serde::de::DeserializeOwned;
#[cfg(feature = "serde_json")]
use crate::d_tag::is_d_tag_base64url;
+#[cfg(feature = "serde_json")]
+use crate::trade::tags::{
+ TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag,
+ parse_trade_prev_tag, parse_trade_root_tag,
+};
#[cfg(feature = "serde_json")]
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -69,6 +74,15 @@ impl std::error::Error for RadrootsTradeEnvelopeParseError {
#[cfg(feature = "serde_json")]
#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeEventContext {
+ pub counterparty_pubkey: String,
+ pub listing_event: Option<RadrootsNostrEventPtr>,
+ pub root_event_id: Option<String>,
+ pub prev_event_id: Option<String>,
+}
+
+#[cfg(feature = "serde_json")]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsTradeListingAddress {
pub kind: u32,
pub seller_pubkey: String,
@@ -182,20 +196,81 @@ pub fn trade_envelope_from_event<T: DeserializeOwned>(
}
}
+ let message_type = envelope.message_type;
+ trade_event_context_from_tags(message_type, &event.tags)?;
+
Ok(envelope)
}
+#[cfg(feature = "serde_json")]
+pub fn trade_event_context_from_tags(
+ message_type: RadrootsTradeMessageType,
+ tags: &[Vec<String>],
+) -> Result<RadrootsTradeEventContext, RadrootsTradeEnvelopeParseError> {
+ let counterparty_pubkey =
+ parse_trade_counterparty_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?;
+ let listing_event =
+ parse_trade_listing_event_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?;
+ let root_event_id =
+ parse_trade_root_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?;
+ let prev_event_id =
+ parse_trade_prev_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?;
+
+ if message_type.requires_listing_snapshot() && listing_event.is_none() {
+ return Err(RadrootsTradeEnvelopeParseError::MissingTag(
+ TAG_LISTING_EVENT,
+ ));
+ }
+ if message_type.requires_trade_chain() {
+ if root_event_id.is_none() {
+ return Err(RadrootsTradeEnvelopeParseError::MissingTag(TAG_E_ROOT));
+ }
+ if prev_event_id.is_none() {
+ return Err(RadrootsTradeEnvelopeParseError::MissingTag(TAG_E_PREV));
+ }
+ }
+
+ Ok(RadrootsTradeEventContext {
+ counterparty_pubkey,
+ listing_event,
+ root_event_id,
+ prev_event_id,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+fn map_tag_parse_error_for_trade_envelope(
+ error: crate::error::EventParseError,
+) -> RadrootsTradeEnvelopeParseError {
+ match error {
+ crate::error::EventParseError::MissingTag(tag) => {
+ RadrootsTradeEnvelopeParseError::MissingTag(tag)
+ }
+ crate::error::EventParseError::InvalidTag(tag) => {
+ RadrootsTradeEnvelopeParseError::InvalidTag(tag)
+ }
+ crate::error::EventParseError::InvalidKind { expected: _, got } => {
+ RadrootsTradeEnvelopeParseError::InvalidKind(got)
+ }
+ crate::error::EventParseError::InvalidNumber(tag, _)
+ | crate::error::EventParseError::InvalidJson(tag) => {
+ RadrootsTradeEnvelopeParseError::InvalidTag(tag)
+ }
+ }
+}
+
#[cfg(all(test, feature = "serde_json"))]
mod tests {
use super::{
RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, trade_envelope_from_event,
+ trade_event_context_from_tags,
};
use crate::trade::encode::trade_envelope_event_build;
use radroots_events::{
- RadrootsNostrEvent,
+ RadrootsNostrEvent, RadrootsNostrEventPtr,
trade::{
RadrootsTradeEnvelope, RadrootsTradeMessagePayload, RadrootsTradeMessageType,
- RadrootsTradeOrder, RadrootsTradeOrderItem, RadrootsTradeOrderStatus,
+ RadrootsTradeOrder, RadrootsTradeOrderItem,
},
};
@@ -210,8 +285,6 @@ mod tests {
bin_count: 3,
}],
discounts: None,
- notes: None,
- status: RadrootsTradeOrderStatus::Requested,
}
}
@@ -230,6 +303,12 @@ mod tests {
RadrootsTradeMessageType::OrderRequest,
"30402:seller:AAAAAAAAAAAAAAAAAAAAAg",
Some("order-1".into()),
+ Some(&RadrootsNostrEventPtr {
+ id: "listing-snapshot".into(),
+ relays: None,
+ }),
+ None,
+ None,
&payload,
)
.expect("build trade envelope");
@@ -259,6 +338,12 @@ mod tests {
RadrootsTradeMessageType::OrderRequest,
"30402:seller:AAAAAAAAAAAAAAAAAAAAAg",
Some("order-1".into()),
+ Some(&RadrootsNostrEventPtr {
+ id: "listing-snapshot".into(),
+ relays: None,
+ }),
+ None,
+ None,
&payload,
)
.expect("build trade envelope");
@@ -277,4 +362,94 @@ mod tests {
let err = trade_envelope_from_event::<serde_json::Value>(&event).unwrap_err();
assert_eq!(err, RadrootsTradeEnvelopeParseError::ListingAddrTagMismatch);
}
+
+ #[test]
+ fn parse_rejects_missing_public_snapshot_tag() {
+ let payload = RadrootsTradeMessagePayload::OrderRequest(base_order());
+ let built = trade_envelope_event_build(
+ "seller",
+ RadrootsTradeMessageType::OrderRequest,
+ "30402:seller:AAAAAAAAAAAAAAAAAAAAAg",
+ Some("order-1".into()),
+ Some(&RadrootsNostrEventPtr {
+ id: "listing-snapshot".into(),
+ relays: None,
+ }),
+ None,
+ None,
+ &payload,
+ )
+ .expect("build trade envelope");
+ let mut event = RadrootsNostrEvent {
+ id: "id".into(),
+ author: "buyer".into(),
+ created_at: 1,
+ kind: built.kind,
+ tags: built.tags,
+ content: built.content,
+ sig: "sig".into(),
+ };
+ event
+ .tags
+ .retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_LISTING_EVENT));
+ let err = trade_envelope_from_event::<RadrootsTradeMessagePayload>(&event).unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsTradeEnvelopeParseError::MissingTag(TAG_LISTING_EVENT)
+ );
+ }
+
+ #[test]
+ fn parse_rejects_missing_public_chain_tags_after_order_request() {
+ let payload = RadrootsTradeMessagePayload::OrderResponse(
+ radroots_events::trade::RadrootsTradeOrderResponse {
+ accepted: true,
+ reason: None,
+ },
+ );
+ let built = trade_envelope_event_build(
+ "buyer",
+ RadrootsTradeMessageType::OrderResponse,
+ "30402:seller:AAAAAAAAAAAAAAAAAAAAAg",
+ Some("order-1".into()),
+ None,
+ Some("root"),
+ Some("prev"),
+ &payload,
+ )
+ .expect("build trade envelope");
+ let mut event = RadrootsNostrEvent {
+ id: "id".into(),
+ author: "seller".into(),
+ created_at: 1,
+ kind: built.kind,
+ tags: built.tags,
+ content: built.content,
+ sig: "sig".into(),
+ };
+ event
+ .tags
+ .retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_E_PREV));
+ let err = trade_envelope_from_event::<RadrootsTradeMessagePayload>(&event).unwrap_err();
+ assert_eq!(err, RadrootsTradeEnvelopeParseError::MissingTag(TAG_E_PREV));
+ }
+
+ #[test]
+ fn parse_trade_event_context_extracts_public_refs() {
+ let context = trade_event_context_from_tags(
+ RadrootsTradeMessageType::OrderResponse,
+ &[
+ vec!["p".into(), "buyer".into()],
+ vec!["a".into(), "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into()],
+ vec![TAG_D.into(), "order-1".into()],
+ vec![TAG_E_ROOT.into(), "root-id".into()],
+ vec![TAG_E_PREV.into(), "prev-id".into()],
+ ],
+ )
+ .expect("event context");
+ assert_eq!(context.counterparty_pubkey, "buyer");
+ assert_eq!(context.root_event_id.as_deref(), Some("root-id"));
+ assert_eq!(context.prev_event_id.as_deref(), Some("prev-id"));
+ assert!(context.listing_event.is_none());
+ }
}
diff --git a/crates/events-codec/src/trade/encode.rs b/crates/events-codec/src/trade/encode.rs
@@ -2,19 +2,58 @@
use alloc::string::String;
#[cfg(feature = "serde_json")]
-use radroots_events::trade::{RadrootsTradeEnvelope, RadrootsTradeMessageType};
+use radroots_events::{
+ RadrootsNostrEventPtr,
+ trade::{
+ RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeMessagePayload,
+ RadrootsTradeMessageType,
+ },
+};
#[cfg(feature = "serde_json")]
-use crate::{trade::tags::trade_envelope_tags, wire::WireEventParts};
+use crate::{error::EventEncodeError, trade::tags::trade_envelope_tags, wire::WireEventParts};
#[cfg(feature = "serde_json")]
-pub fn trade_envelope_event_build<T: serde::Serialize + Clone>(
+fn map_envelope_error(error: RadrootsTradeEnvelopeError) -> EventEncodeError {
+ match error {
+ RadrootsTradeEnvelopeError::MissingOrderId => {
+ EventEncodeError::EmptyRequiredField("order_id")
+ }
+ RadrootsTradeEnvelopeError::MissingListingAddr => {
+ EventEncodeError::EmptyRequiredField("listing_addr")
+ }
+ RadrootsTradeEnvelopeError::InvalidVersion { .. } => {
+ EventEncodeError::InvalidField("version")
+ }
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub fn trade_envelope_event_build(
recipient_pubkey: impl Into<String>,
message_type: RadrootsTradeMessageType,
listing_addr: impl Into<String>,
order_id: Option<String>,
- payload: &T,
-) -> Result<WireEventParts, serde_json::Error> {
+ listing_event: Option<&RadrootsNostrEventPtr>,
+ root_event_id: Option<&str>,
+ prev_event_id: Option<&str>,
+ payload: &RadrootsTradeMessagePayload,
+) -> Result<WireEventParts, EventEncodeError> {
+ if payload.message_type() != message_type {
+ return Err(EventEncodeError::InvalidField("payload"));
+ }
+ if message_type.requires_listing_snapshot() && listing_event.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_event.id"));
+ }
+ if message_type.requires_trade_chain() {
+ if root_event_id.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("root_event_id"));
+ }
+ if prev_event_id.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("prev_event_id"));
+ }
+ }
+
let listing_addr = listing_addr.into();
let envelope = RadrootsTradeEnvelope::new(
message_type,
@@ -22,8 +61,16 @@ pub fn trade_envelope_event_build<T: serde::Serialize + 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());
+ envelope.validate().map_err(map_envelope_error)?;
+ let content = serde_json::to_string(&envelope).map_err(|_| EventEncodeError::Json)?;
+ let tags = trade_envelope_tags(
+ recipient_pubkey,
+ &listing_addr,
+ order_id.as_deref(),
+ listing_event,
+ root_event_id,
+ prev_event_id,
+ )?;
Ok(WireEventParts {
kind: message_type.kind(),
content,
diff --git a/crates/events-codec/src/trade/mod.rs b/crates/events-codec/src/trade/mod.rs
@@ -4,9 +4,13 @@ pub mod tags;
#[cfg(feature = "serde_json")]
pub use decode::{
- RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, RadrootsTradeListingAddressError,
- trade_envelope_from_event,
+ RadrootsTradeEnvelopeParseError, RadrootsTradeEventContext, RadrootsTradeListingAddress,
+ RadrootsTradeListingAddressError, trade_envelope_from_event, trade_event_context_from_tags,
};
#[cfg(feature = "serde_json")]
pub use encode::trade_envelope_event_build;
-pub use tags::{push_trade_chain_tags, trade_envelope_tags, validate_trade_chain};
+pub use tags::{
+ TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag,
+ parse_trade_prev_tag, parse_trade_root_tag, 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
@@ -1,9 +1,17 @@
#[cfg(not(feature = "std"))]
use alloc::{borrow::ToOwned, string::String, vec::Vec};
-use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT};
+use radroots_events::{
+ RadrootsNostrEventPtr,
+ tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT},
+};
-use crate::job::error::JobParseError;
+use crate::{
+ error::{EventEncodeError, EventParseError},
+ job::error::JobParseError,
+};
+
+pub const TAG_LISTING_EVENT: &str = "listing_event";
#[inline]
fn push_tag(tags: &mut Vec<Vec<String>>, name: &'static str, value: impl Into<String>) {
@@ -13,24 +21,158 @@ fn push_tag(tags: &mut Vec<Vec<String>>, name: &'static str, value: impl Into<St
tags.push(tag);
}
+fn build_event_ptr_tag(
+ name: &'static str,
+ ptr: &RadrootsNostrEventPtr,
+ field_prefix: &'static str,
+) -> Result<Vec<String>, EventEncodeError> {
+ if ptr.id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField(field_prefix));
+ }
+ let mut tag = Vec::with_capacity(3);
+ tag.push(name.to_owned());
+ tag.push(ptr.id.clone());
+ if let Some(relay) = &ptr.relays {
+ if relay.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_event.relays"));
+ }
+ tag.push(relay.clone());
+ }
+ Ok(tag)
+}
+
+fn parse_event_ptr_tag(
+ tags: &[Vec<String>],
+ name: &'static str,
+) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> {
+ let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(name))
+ else {
+ return Ok(None);
+ };
+ let id = tag.get(1).ok_or(EventParseError::InvalidTag(name))?;
+ if id.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(name));
+ }
+ let relay = match tag.get(2) {
+ Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag(name)),
+ Some(value) => Some(value.clone()),
+ None => None,
+ };
+ Ok(Some(RadrootsNostrEventPtr {
+ id: id.clone(),
+ relays: relay,
+ }))
+}
+
#[inline]
pub fn trade_envelope_tags<P, A, D>(
recipient_pubkey: P,
listing_addr: A,
order_id: Option<D>,
-) -> Vec<Vec<String>>
+ listing_event: Option<&RadrootsNostrEventPtr>,
+ root_event_id: Option<&str>,
+ prev_event_id: Option<&str>,
+) -> Result<Vec<Vec<String>>, EventEncodeError>
where
P: Into<String>,
A: Into<String>,
D: Into<String>,
{
- let mut tags = Vec::with_capacity(2 + usize::from(order_id.is_some()));
+ let recipient_pubkey = recipient_pubkey.into();
+ if recipient_pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("recipient_pubkey"));
+ }
+ let listing_addr = listing_addr.into();
+ if listing_addr.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_addr"));
+ }
+
+ let mut capacity = 2 + usize::from(order_id.is_some()) + usize::from(listing_event.is_some());
+ capacity += usize::from(root_event_id.is_some()) + usize::from(prev_event_id.is_some());
+ let mut tags = Vec::with_capacity(capacity);
push_tag(&mut tags, "p", recipient_pubkey);
push_tag(&mut tags, "a", listing_addr);
if let Some(order_id) = order_id {
+ let order_id = order_id.into();
+ if order_id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("order_id"));
+ }
push_tag(&mut tags, TAG_D, order_id);
}
- tags
+ if let Some(listing_event) = listing_event {
+ tags.push(build_event_ptr_tag(
+ TAG_LISTING_EVENT,
+ listing_event,
+ "listing_event.id",
+ )?);
+ }
+ if let Some(root_event_id) = root_event_id {
+ if root_event_id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("root_event_id"));
+ }
+ push_tag(&mut tags, TAG_E_ROOT, root_event_id);
+ }
+ if let Some(prev_event_id) = prev_event_id {
+ if prev_event_id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("prev_event_id"));
+ }
+ push_tag(&mut tags, TAG_E_PREV, prev_event_id);
+ }
+ Ok(tags)
+}
+
+#[inline]
+pub fn parse_trade_counterparty_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some("p"))
+ .ok_or(EventParseError::MissingTag("p"))?;
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag("p"));
+ }
+ Ok(value.clone())
+}
+
+#[inline]
+pub fn parse_trade_listing_event_tag(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> {
+ parse_event_ptr_tag(tags, TAG_LISTING_EVENT)
+}
+
+#[inline]
+pub fn parse_trade_root_tag(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> {
+ let tag = match tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT))
+ {
+ Some(tag) => tag,
+ None => return Ok(None),
+ };
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_ROOT))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_E_ROOT));
+ }
+ Ok(Some(value.clone()))
+}
+
+#[inline]
+pub fn parse_trade_prev_tag(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> {
+ let tag = match tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_PREV))
+ {
+ Some(tag) => tag,
+ None => return Ok(None),
+ };
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_PREV))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_E_PREV));
+ }
+ Ok(Some(value.clone()))
}
#[inline]
@@ -93,8 +235,13 @@ pub fn validate_trade_chain(tags: &[Vec<String>]) -> Result<(), JobParseError> {
#[cfg(test)]
mod tests {
- use super::{push_trade_chain_tags, trade_envelope_tags, validate_trade_chain};
+ use super::{
+ TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag,
+ parse_trade_prev_tag, parse_trade_root_tag, push_trade_chain_tags, trade_envelope_tags,
+ validate_trade_chain,
+ };
use radroots_events::{
+ RadrootsNostrEventPtr,
kinds::KIND_LISTING,
tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT},
};
@@ -102,7 +249,8 @@ mod tests {
#[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 tags = trade_envelope_tags("pubkey", &listing_addr, Some("order-1"), None, None, None)
+ .expect("trade tags");
let expected: Vec<Vec<String>> = vec![
vec![String::from("p"), String::from("pubkey")],
vec![String::from("a"), listing_addr],
@@ -112,6 +260,74 @@ mod tests {
}
#[test]
+ fn trade_envelope_tags_include_snapshot_and_chain_refs() {
+ let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let tags = trade_envelope_tags(
+ "buyer",
+ &listing_addr,
+ Some("order-1"),
+ Some(&RadrootsNostrEventPtr {
+ id: "listing-snapshot".into(),
+ relays: Some("wss://relay.example".into()),
+ }),
+ Some("root-event"),
+ Some("prev-event"),
+ )
+ .expect("trade tags");
+ assert!(tags.iter().any(|tag| {
+ tag.as_slice()
+ == [
+ TAG_LISTING_EVENT.to_string(),
+ "listing-snapshot".to_string(),
+ "wss://relay.example".to_string(),
+ ]
+ }));
+ assert!(
+ tags.iter().any(|tag| {
+ tag.as_slice() == [TAG_E_ROOT.to_string(), "root-event".to_string()]
+ })
+ );
+ assert!(
+ tags.iter().any(|tag| {
+ tag.as_slice() == [TAG_E_PREV.to_string(), "prev-event".to_string()]
+ })
+ );
+ }
+
+ #[test]
+ fn trade_envelope_tag_parsers_cover_public_context() {
+ let tags = vec![
+ vec!["p".into(), "counterparty".into()],
+ vec![
+ TAG_LISTING_EVENT.into(),
+ "snapshot".into(),
+ "wss://relay".into(),
+ ],
+ vec![TAG_E_ROOT.into(), "root".into()],
+ vec![TAG_E_PREV.into(), "prev".into()],
+ ];
+ assert_eq!(
+ parse_trade_counterparty_tag(&tags).expect("counterparty"),
+ "counterparty"
+ );
+ assert_eq!(
+ parse_trade_listing_event_tag(&tags).expect("snapshot"),
+ Some(RadrootsNostrEventPtr {
+ id: "snapshot".into(),
+ relays: Some("wss://relay".into()),
+ })
+ );
+ assert_eq!(
+ parse_trade_root_tag(&tags).expect("root"),
+ Some("root".into())
+ );
+ assert_eq!(
+ parse_trade_prev_tag(&tags).expect("prev"),
+ Some("prev".into())
+ );
+ }
+
+ #[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"));
diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs
@@ -146,10 +146,7 @@ pub enum RadrootsTradeOrderChange {
#[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))]
@@ -185,9 +182,6 @@ pub struct RadrootsTradeOrder {
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))]
@@ -196,11 +190,6 @@ pub struct RadrootsTradeOrder {
#[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))]
@@ -209,11 +198,6 @@ pub struct RadrootsTradeQuestion {
#[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))]
@@ -222,11 +206,8 @@ pub struct RadrootsTradeAnswer {
#[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))]
@@ -235,11 +216,8 @@ pub struct RadrootsTradeDiscountRequest {
#[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))]
@@ -283,12 +261,6 @@ pub enum RadrootsTradeFulfillmentStatus {
#[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))]
@@ -298,8 +270,6 @@ pub struct RadrootsTradeFulfillmentUpdate {
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))]
@@ -481,6 +451,19 @@ impl RadrootsTradeMessageType {
}
#[inline]
+ pub const fn requires_listing_snapshot(self) -> bool {
+ matches!(
+ self,
+ Self::OrderRequest | Self::OrderRevision | Self::DiscountRequest | Self::DiscountOffer
+ )
+ }
+
+ #[inline]
+ pub const fn requires_trade_chain(self) -> bool {
+ self.is_public() && !matches!(self, Self::OrderRequest)
+ }
+
+ #[inline]
pub const fn is_request(self) -> bool {
matches!(
self,
@@ -608,6 +591,30 @@ pub enum RadrootsTradeMessagePayload {
Receipt(RadrootsTradeReceipt),
}
+impl RadrootsTradeMessagePayload {
+ #[inline]
+ pub const fn message_type(&self) -> RadrootsTradeMessageType {
+ match self {
+ Self::ListingValidateRequest(_) => RadrootsTradeMessageType::ListingValidateRequest,
+ Self::ListingValidateResult(_) => RadrootsTradeMessageType::ListingValidateResult,
+ Self::OrderRequest(_) => RadrootsTradeMessageType::OrderRequest,
+ Self::OrderResponse(_) => RadrootsTradeMessageType::OrderResponse,
+ Self::OrderRevision(_) => RadrootsTradeMessageType::OrderRevision,
+ Self::OrderRevisionAccept(_) => RadrootsTradeMessageType::OrderRevisionAccept,
+ Self::OrderRevisionDecline(_) => RadrootsTradeMessageType::OrderRevisionDecline,
+ Self::Question(_) => RadrootsTradeMessageType::Question,
+ Self::Answer(_) => RadrootsTradeMessageType::Answer,
+ Self::DiscountRequest(_) => RadrootsTradeMessageType::DiscountRequest,
+ Self::DiscountOffer(_) => RadrootsTradeMessageType::DiscountOffer,
+ Self::DiscountAccept(_) => RadrootsTradeMessageType::DiscountAccept,
+ Self::DiscountDecline(_) => RadrootsTradeMessageType::DiscountDecline,
+ Self::Cancel(_) => RadrootsTradeMessageType::Cancel,
+ Self::FulfillmentUpdate(_) => RadrootsTradeMessageType::FulfillmentUpdate,
+ Self::Receipt(_) => RadrootsTradeMessageType::Receipt,
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -641,8 +648,6 @@ mod tests {
seller_pubkey: "seller".into(),
items: vec![],
discounts: None,
- notes: None,
- status: RadrootsTradeOrderStatus::Requested,
}),
);
assert_eq!(
diff --git a/crates/trade/src/listing/dvm.rs b/crates/trade/src/listing/dvm.rs
@@ -8,6 +8,8 @@ use radroots_events::{RadrootsNostrEvent, tags::TAG_D};
use radroots_events::{RadrootsNostrEventPtr, kinds::KIND_PROFILE};
use radroots_events_codec::d_tag::is_d_tag_base64url;
#[cfg(feature = "serde_json")]
+use radroots_events_codec::error::{EventEncodeError, EventParseError};
+#[cfg(feature = "serde_json")]
use serde::de::DeserializeOwned;
#[cfg(feature = "ts-rs")]
use ts_rs::TS;
@@ -28,6 +30,11 @@ use crate::listing::order::{
};
#[cfg(feature = "serde_json")]
use crate::listing::tags::trade_listing_dvm_tags;
+#[cfg(feature = "serde_json")]
+use crate::listing::tags::{
+ TAG_LISTING_EVENT, parse_trade_listing_counterparty_tag, parse_trade_listing_event_tag,
+ parse_trade_listing_prev_tag, parse_trade_listing_root_tag,
+};
use crate::listing::validation::TradeListingValidationError;
pub const TRADE_LISTING_DOMAIN: &str = "trade:listing";
@@ -128,6 +135,27 @@ impl TradeListingMessageType {
}
#[inline]
+ pub const fn requires_listing_snapshot(self) -> bool {
+ matches!(
+ self,
+ TradeListingMessageType::OrderRequest
+ | TradeListingMessageType::OrderRevision
+ | TradeListingMessageType::DiscountRequest
+ | TradeListingMessageType::DiscountOffer
+ )
+ }
+
+ #[inline]
+ pub const fn requires_trade_chain(self) -> bool {
+ !matches!(
+ self,
+ TradeListingMessageType::ListingValidateRequest
+ | TradeListingMessageType::ListingValidateResult
+ | TradeListingMessageType::OrderRequest
+ )
+ }
+
+ #[inline]
pub const fn is_request(self) -> bool {
matches!(
self,
@@ -212,13 +240,45 @@ pub struct TradeListingEnvelopeEvent {
}
#[cfg(feature = "serde_json")]
-pub fn trade_listing_envelope_event_build<T: serde::Serialize + Clone>(
+fn map_envelope_error(error: TradeListingEnvelopeError) -> EventEncodeError {
+ match error {
+ TradeListingEnvelopeError::MissingOrderId => {
+ EventEncodeError::EmptyRequiredField("order_id")
+ }
+ TradeListingEnvelopeError::MissingListingAddr => {
+ EventEncodeError::EmptyRequiredField("listing_addr")
+ }
+ TradeListingEnvelopeError::InvalidVersion { .. } => {
+ EventEncodeError::InvalidField("version")
+ }
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub fn trade_listing_envelope_event_build(
recipient_pubkey: impl Into<String>,
message_type: TradeListingMessageType,
listing_addr: impl Into<String>,
order_id: Option<String>,
- payload: &T,
-) -> Result<TradeListingEnvelopeEvent, serde_json::Error> {
+ listing_event: Option<&RadrootsNostrEventPtr>,
+ root_event_id: Option<&str>,
+ prev_event_id: Option<&str>,
+ payload: &TradeListingMessagePayload,
+) -> Result<TradeListingEnvelopeEvent, EventEncodeError> {
+ if payload.message_type() != message_type {
+ return Err(EventEncodeError::InvalidField("payload"));
+ }
+ if message_type.requires_listing_snapshot() && listing_event.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_event.id"));
+ }
+ if message_type.requires_trade_chain() {
+ if root_event_id.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("root_event_id"));
+ }
+ if prev_event_id.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("prev_event_id"));
+ }
+ }
let listing_addr = listing_addr.into();
let envelope = TradeListingEnvelope::new(
message_type,
@@ -226,8 +286,16 @@ pub fn trade_listing_envelope_event_build<T: serde::Serialize + Clone>(
order_id.clone(),
payload.clone(),
);
- let content = serde_json::to_string(&envelope)?;
- let tags = trade_listing_dvm_tags(recipient_pubkey, &listing_addr, order_id.as_deref());
+ envelope.validate().map_err(map_envelope_error)?;
+ let content = serde_json::to_string(&envelope).map_err(|_| EventEncodeError::Json)?;
+ let tags = trade_listing_dvm_tags(
+ recipient_pubkey,
+ &listing_addr,
+ order_id.as_deref(),
+ listing_event,
+ root_event_id,
+ prev_event_id,
+ )?;
Ok(TradeListingEnvelopeEvent {
kind: message_type.kind(),
content,
@@ -282,6 +350,15 @@ pub enum TradeListingEnvelopeParseError {
}
#[cfg(feature = "serde_json")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct TradeListingEventContext {
+ pub counterparty_pubkey: String,
+ pub listing_event: Option<RadrootsNostrEventPtr>,
+ pub root_event_id: Option<String>,
+ pub prev_event_id: Option<String>,
+}
+
+#[cfg(feature = "serde_json")]
impl core::fmt::Display for TradeListingEnvelopeParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
@@ -445,10 +522,61 @@ where
}
}
+ trade_listing_event_context_from_tags(envelope.message_type, &event.tags)?;
+
Ok(envelope)
}
}
+#[cfg(feature = "serde_json")]
+pub fn trade_listing_event_context_from_tags(
+ message_type: TradeListingMessageType,
+ tags: &[Vec<String>],
+) -> Result<TradeListingEventContext, TradeListingEnvelopeParseError> {
+ let counterparty_pubkey =
+ parse_trade_listing_counterparty_tag(tags).map_err(map_event_parse_error)?;
+ let listing_event = parse_trade_listing_event_tag(tags).map_err(map_event_parse_error)?;
+ let root_event_id = parse_trade_listing_root_tag(tags).map_err(map_event_parse_error)?;
+ let prev_event_id = parse_trade_listing_prev_tag(tags).map_err(map_event_parse_error)?;
+ if message_type.requires_listing_snapshot() && listing_event.is_none() {
+ return Err(TradeListingEnvelopeParseError::MissingTag(
+ TAG_LISTING_EVENT,
+ ));
+ }
+ if message_type.requires_trade_chain() {
+ if root_event_id.is_none() {
+ return Err(TradeListingEnvelopeParseError::MissingTag(
+ radroots_events::tags::TAG_E_ROOT,
+ ));
+ }
+ if prev_event_id.is_none() {
+ return Err(TradeListingEnvelopeParseError::MissingTag(
+ radroots_events::tags::TAG_E_PREV,
+ ));
+ }
+ }
+ Ok(TradeListingEventContext {
+ counterparty_pubkey,
+ listing_event,
+ root_event_id,
+ prev_event_id,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+fn map_event_parse_error(error: EventParseError) -> TradeListingEnvelopeParseError {
+ match error {
+ EventParseError::MissingTag(tag) => TradeListingEnvelopeParseError::MissingTag(tag),
+ EventParseError::InvalidTag(tag) => TradeListingEnvelopeParseError::InvalidTag(tag),
+ EventParseError::InvalidKind { expected: _, got } => {
+ TradeListingEnvelopeParseError::InvalidKind(got)
+ }
+ EventParseError::InvalidNumber(tag, _) | EventParseError::InvalidJson(tag) => {
+ TradeListingEnvelopeParseError::InvalidTag(tag)
+ }
+ }
+}
+
#[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))]
@@ -524,19 +652,61 @@ pub enum TradeListingMessagePayload {
Receipt(TradeReceipt),
}
+impl TradeListingMessagePayload {
+ pub const fn message_type(&self) -> TradeListingMessageType {
+ match self {
+ TradeListingMessagePayload::ListingValidateRequest(_) => {
+ TradeListingMessageType::ListingValidateRequest
+ }
+ TradeListingMessagePayload::ListingValidateResult(_) => {
+ TradeListingMessageType::ListingValidateResult
+ }
+ TradeListingMessagePayload::OrderRequest(_) => TradeListingMessageType::OrderRequest,
+ TradeListingMessagePayload::OrderResponse(_) => TradeListingMessageType::OrderResponse,
+ TradeListingMessagePayload::OrderRevision(_) => TradeListingMessageType::OrderRevision,
+ TradeListingMessagePayload::OrderRevisionAccept(_) => {
+ TradeListingMessageType::OrderRevisionAccept
+ }
+ TradeListingMessagePayload::OrderRevisionDecline(_) => {
+ TradeListingMessageType::OrderRevisionDecline
+ }
+ TradeListingMessagePayload::Question(_) => TradeListingMessageType::Question,
+ TradeListingMessagePayload::Answer(_) => TradeListingMessageType::Answer,
+ TradeListingMessagePayload::DiscountRequest(_) => {
+ TradeListingMessageType::DiscountRequest
+ }
+ TradeListingMessagePayload::DiscountOffer(_) => TradeListingMessageType::DiscountOffer,
+ TradeListingMessagePayload::DiscountAccept(_) => {
+ TradeListingMessageType::DiscountAccept
+ }
+ TradeListingMessagePayload::DiscountDecline(_) => {
+ TradeListingMessageType::DiscountDecline
+ }
+ TradeListingMessagePayload::Cancel(_) => TradeListingMessageType::Cancel,
+ TradeListingMessagePayload::FulfillmentUpdate(_) => {
+ TradeListingMessageType::FulfillmentUpdate
+ }
+ TradeListingMessagePayload::Receipt(_) => TradeListingMessageType::Receipt,
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::{
TradeListingAddress, TradeListingAddressError, TradeListingEnvelope,
TradeListingEnvelopeError, TradeListingEnvelopeParseError, TradeListingMessagePayload,
- TradeListingMessageType, TradeListingValidateRequest, trade_listing_envelope_event_build,
+ TradeListingMessageType, TradeListingValidateRequest, TradeOrderResponse,
+ trade_listing_envelope_event_build,
};
- #[cfg(feature = "serde_json")]
- use radroots_events::RadrootsNostrEvent;
use radroots_events::kinds::KIND_LISTING;
+ #[cfg(feature = "serde_json")]
+ use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr};
+ #[cfg(feature = "serde_json")]
+ use radroots_events_codec::error::EventEncodeError;
#[cfg(feature = "serde_json")]
- use crate::listing::order::{TradeOrder, TradeOrderItem, TradeOrderStatus};
+ use crate::listing::order::{TradeOrder, TradeOrderItem};
#[test]
fn envelope_requires_listing_addr() {
@@ -776,8 +946,14 @@ mod tests {
bin_count: 2,
}],
discounts: None,
- notes: Some("deliver friday".into()),
- status: TradeOrderStatus::Draft,
+ }
+ }
+
+ #[cfg(feature = "serde_json")]
+ fn listing_snapshot() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: "listing-event-id".into(),
+ relays: None,
}
}
@@ -790,11 +966,18 @@ mod tests {
order_id: Option<&str>,
payload: &TradeListingMessagePayload,
) -> RadrootsNostrEvent {
+ let message_type = payload.message_type();
+ let listing_event = message_type
+ .requires_listing_snapshot()
+ .then(listing_snapshot);
let built = trade_listing_envelope_event_build(
recipient_pubkey,
message_type,
listing_addr.to_string(),
order_id.map(str::to_string),
+ listing_event.as_ref(),
+ None,
+ None,
payload,
)
.expect("canonical envelope event");
@@ -810,45 +993,18 @@ mod tests {
}
#[cfg(feature = "serde_json")]
- #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
- struct EnvelopePayload {
- fail: bool,
- }
-
- #[cfg(feature = "serde_json")]
- impl EnvelopePayload {
- fn ok() -> Self {
- Self { fail: false }
- }
-
- fn fail() -> Self {
- Self { fail: true }
- }
- }
-
- #[cfg(feature = "serde_json")]
- impl serde::Serialize for EnvelopePayload {
- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: serde::Serializer,
- {
- if self.fail {
- return Err(serde::ser::Error::custom("intentional"));
- }
- serializer.serialize_str("ok")
- }
- }
-
- #[cfg(feature = "serde_json")]
#[test]
- fn envelope_event_build_includes_order_tag() {
+ fn envelope_event_build_includes_order_and_snapshot_tags() {
let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
- let payload = EnvelopePayload::ok();
+ let payload = TradeListingMessagePayload::OrderRequest(base_order());
let built = super::trade_listing_envelope_event_build(
"pubkey",
TradeListingMessageType::OrderRequest,
listing_addr.clone(),
Some(String::from("order-1")),
+ Some(&listing_snapshot()),
+ None,
+ None,
&payload,
)
.unwrap();
@@ -859,19 +1015,25 @@ mod tests {
serde_json::from_str(&built.content).unwrap();
assert_eq!(envelope.listing_addr, listing_addr.clone());
assert_eq!(envelope.order_id.as_deref(), Some("order-1"));
- assert_eq!(built.tags.len(), 3);
+ assert_eq!(built.tags.len(), 4);
}
#[cfg(feature = "serde_json")]
#[test]
fn envelope_event_build_omits_order_tag_when_missing() {
let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
- let payload = EnvelopePayload::ok();
+ let payload =
+ TradeListingMessagePayload::ListingValidateRequest(TradeListingValidateRequest {
+ listing_event: None,
+ });
let built = super::trade_listing_envelope_event_build(
"pubkey",
TradeListingMessageType::ListingValidateRequest,
listing_addr.clone(),
None,
+ None,
+ None,
+ None,
&payload,
)
.unwrap();
@@ -890,18 +1052,46 @@ mod tests {
#[cfg(feature = "serde_json")]
#[test]
- fn envelope_event_build_propagates_payload_serialization_error() {
+ fn envelope_event_build_requires_snapshot_for_order_request() {
let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
- let payload = EnvelopePayload::fail();
+ let payload = TradeListingMessagePayload::OrderRequest(base_order());
let err = super::trade_listing_envelope_event_build(
"pubkey",
- TradeListingMessageType::ListingValidateRequest,
+ TradeListingMessageType::OrderRequest,
+ listing_addr,
+ Some(String::from("order-1")),
+ None,
+ None,
+ None,
+ &payload,
+ )
+ .unwrap_err();
+ assert_eq!(
+ err,
+ EventEncodeError::EmptyRequiredField("listing_event.id")
+ );
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn envelope_event_build_requires_chain_tags_for_order_response() {
+ let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let payload = TradeListingMessagePayload::OrderResponse(TradeOrderResponse {
+ accepted: true,
+ reason: None,
+ });
+ let err = super::trade_listing_envelope_event_build(
+ "buyer-pubkey",
+ TradeListingMessageType::OrderResponse,
listing_addr,
+ Some(String::from("order-1")),
+ None,
+ None,
None,
&payload,
)
.unwrap_err();
- assert!(err.to_string().contains("intentional"));
+ assert_eq!(err, EventEncodeError::EmptyRequiredField("root_event_id"));
}
#[cfg(feature = "serde_json")]
diff --git a/crates/trade/src/listing/order.rs b/crates/trade/src/listing/order.rs
@@ -36,10 +36,7 @@ pub enum TradeOrderChange {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeOrderRevision {
pub revision_id: String,
- pub order_id: String,
pub changes: Vec<TradeOrderChange>,
- #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
- pub reason: Option<String>,
}
#[cfg_attr(feature = "ts-rs", derive(TS))]
@@ -57,9 +54,6 @@ pub struct TradeOrder {
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: TradeOrderStatus,
}
#[cfg_attr(feature = "ts-rs", derive(TS))]
@@ -86,11 +80,6 @@ pub enum TradeOrderStatus {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeQuestion {
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))]
@@ -99,11 +88,6 @@ pub struct TradeQuestion {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeAnswer {
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))]
@@ -112,11 +96,8 @@ pub struct TradeAnswer {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeDiscountRequest {
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))]
@@ -125,11 +106,8 @@ pub struct TradeDiscountRequest {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeDiscountOffer {
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))]
@@ -173,12 +151,6 @@ pub enum TradeFulfillmentStatus {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeFulfillmentUpdate {
pub status: TradeFulfillmentStatus,
- #[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))]
@@ -188,6 +160,4 @@ pub struct TradeFulfillmentUpdate {
pub struct TradeReceipt {
pub acknowledged: bool,
pub at: u64,
- #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
- pub note: Option<String>,
}
diff --git a/crates/trade/src/listing/overlay.rs b/crates/trade/src/listing/overlay.rs
@@ -496,6 +496,8 @@ fn order_backoffice_matches_query(
#[cfg(test)]
mod tests {
+ use std::cell::RefCell;
+
use super::{
RadrootsTradeBackofficeOverlayError, RadrootsTradeBackofficeOverlayStore,
RadrootsTradeFulfillmentException, RadrootsTradeFulfillmentExceptionSeverity,
@@ -524,12 +526,128 @@ mod tests {
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent,
RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
+ use radroots_events::RadrootsNostrEventPtr;
use radroots_events::listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
RadrootsListingProduct, RadrootsListingStatus,
};
+ #[derive(Clone, Debug)]
+ struct TestWorkflowChain {
+ buyer_pubkey: String,
+ seller_pubkey: String,
+ root_event_id: String,
+ last_event_id: String,
+ next_sequence: u32,
+ }
+
+ thread_local! {
+ static TEST_WORKFLOW_CHAINS: RefCell<std::collections::BTreeMap<String, TestWorkflowChain>> =
+ RefCell::new(std::collections::BTreeMap::new());
+ }
+
+ fn listing_snapshot(listing_addr: &str) -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: format!("snapshot:{listing_addr}"),
+ relays: None,
+ }
+ }
+
+ fn seller_pubkey_from_listing_addr(listing_addr: &str) -> String {
+ listing_addr
+ .split(':')
+ .nth(1)
+ .unwrap_or_default()
+ .to_string()
+ }
+
+ fn workflow_refs(
+ actor_pubkey: &str,
+ listing_addr: &str,
+ order_id: Option<&str>,
+ payload: &TradeListingMessagePayload,
+ ) -> (
+ String,
+ String,
+ Option<RadrootsNostrEventPtr>,
+ Option<String>,
+ Option<String>,
+ ) {
+ let message_type = payload.message_type();
+ let listing_event = message_type
+ .requires_listing_snapshot()
+ .then(|| listing_snapshot(listing_addr));
+ let default_seller = seller_pubkey_from_listing_addr(listing_addr);
+
+ match (message_type, order_id) {
+ (_, None) => (
+ format!("event:no-order:{}:{actor_pubkey}", message_type.kind()),
+ default_seller,
+ listing_event,
+ None,
+ None,
+ ),
+ (crate::listing::dvm::TradeListingMessageType::OrderRequest, Some(order_id)) => {
+ let order = match payload {
+ TradeListingMessagePayload::OrderRequest(order) => order,
+ _ => unreachable!("order request payload should match message type"),
+ };
+ let event_id = format!("{order_id}:request");
+ TEST_WORKFLOW_CHAINS.with(|chains| {
+ chains.borrow_mut().insert(
+ order_id.to_string(),
+ TestWorkflowChain {
+ buyer_pubkey: order.buyer_pubkey.clone(),
+ seller_pubkey: order.seller_pubkey.clone(),
+ root_event_id: event_id.clone(),
+ last_event_id: event_id.clone(),
+ next_sequence: 1,
+ },
+ );
+ });
+ (
+ event_id,
+ order.seller_pubkey.clone(),
+ listing_event,
+ None,
+ None,
+ )
+ }
+ (_, Some(order_id)) => TEST_WORKFLOW_CHAINS.with(|chains| {
+ let mut chains = chains.borrow_mut();
+ let chain =
+ chains
+ .entry(order_id.to_string())
+ .or_insert_with(|| TestWorkflowChain {
+ buyer_pubkey: String::from("buyer-pubkey"),
+ seller_pubkey: default_seller.clone(),
+ root_event_id: format!("{order_id}:root"),
+ last_event_id: format!("{order_id}:root"),
+ next_sequence: 1,
+ });
+ let event_id =
+ format!("{order_id}:{}:{}", message_type.kind(), chain.next_sequence);
+ chain.next_sequence += 1;
+ let counterparty_pubkey = if actor_pubkey == chain.seller_pubkey {
+ chain.buyer_pubkey.clone()
+ } else {
+ chain.seller_pubkey.clone()
+ };
+ let prev_event_id = chain.last_event_id.clone();
+ let root_event_id = chain.root_event_id.clone();
+ chain.last_event_id = event_id.clone();
+ (
+ event_id,
+ counterparty_pubkey,
+ listing_event,
+ Some(root_event_id),
+ Some(prev_event_id),
+ )
+ }),
+ }
+ }
+
fn base_listing() -> RadrootsListing {
RadrootsListing {
d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(),
@@ -673,8 +791,6 @@ mod tests {
discounts: Some(vec![radroots_core::RadrootsCoreDiscountValue::Percent(
RadrootsCorePercent::new(RadrootsCoreDecimal::from(10u32)),
)]),
- notes: Some("deliver friday".into()),
- status: TradeOrderStatus::Draft,
}
}
@@ -689,8 +805,6 @@ mod tests {
bin_count: 3,
}],
discounts: None,
- notes: Some("expedite".into()),
- status: TradeOrderStatus::Draft,
}
}
@@ -700,10 +814,17 @@ mod tests {
order_id: Option<&str>,
payload: TradeListingMessagePayload,
) -> RadrootsTradeOrderWorkflowMessage {
+ let (event_id, counterparty_pubkey, listing_event, root_event_id, prev_event_id) =
+ workflow_refs(actor_pubkey, listing_addr, order_id, &payload);
RadrootsTradeOrderWorkflowMessage {
+ event_id,
actor_pubkey: actor_pubkey.into(),
+ counterparty_pubkey,
listing_addr: listing_addr.into(),
order_id: order_id.map(str::to_string),
+ listing_event,
+ root_event_id,
+ prev_event_id,
payload,
}
}
@@ -816,9 +937,6 @@ mod tests {
Some("order-2"),
TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate {
status: TradeFulfillmentStatus::Delivered,
- tracking: Some("track-2".into()),
- eta: None,
- notes: Some("in transit".into()),
}),
))
.expect("fulfilled");
@@ -830,7 +948,6 @@ mod tests {
TradeListingMessagePayload::Receipt(TradeReceipt {
acknowledged: true,
at: 1_700_000_020,
- note: Some("all good".into()),
}),
))
.expect("completed");
diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs
@@ -9,7 +9,7 @@ use std::collections::BTreeMap;
use radroots_core::{RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue};
use radroots_events::{
- RadrootsNostrEvent,
+ RadrootsNostrEvent, RadrootsNostrEventPtr,
kinds::{KIND_LISTING, is_listing_kind},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
@@ -34,6 +34,8 @@ use crate::listing::{
},
price_ext::BinPricingExt,
};
+#[cfg(feature = "serde_json")]
+use radroots_events_codec::trade::trade_event_context_from_tags;
#[cfg_attr(feature = "ts-rs", derive(TS))]
#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
@@ -116,9 +118,11 @@ pub struct RadrootsTradeOrderWorkflowProjection {
ts(optional, type = "RadrootsCoreDiscountValue[] | null")
)]
pub requested_discounts: Option<Vec<RadrootsCoreDiscountValue>>,
- #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
- pub notes: Option<String>,
pub status: TradeOrderStatus,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))]
+ pub listing_snapshot: Option<RadrootsNostrEventPtr>,
+ pub root_event_id: String,
+ pub last_event_id: String,
#[cfg_attr(
feature = "ts-rs",
ts(optional, type = "RadrootsCoreDiscountValue | null")
@@ -166,10 +170,18 @@ pub struct RadrootsTradeOrderWorkflowProjection {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsTradeOrderWorkflowMessage {
+ pub event_id: String,
pub actor_pubkey: String,
+ pub counterparty_pubkey: String,
pub listing_addr: String,
#[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
pub order_id: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))]
+ pub listing_event: Option<RadrootsNostrEventPtr>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub root_event_id: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub prev_event_id: Option<String>,
#[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeMessagePayload"))]
pub payload: TradeListingMessagePayload,
}
@@ -421,6 +433,12 @@ pub enum RadrootsTradeProjectionError {
InvalidRevisionResponse,
NonOrderWorkflowMessage(TradeListingMessageType),
UnauthorizedActor,
+ CounterpartyMismatch,
+ MissingListingSnapshot,
+ MissingTradeRootEventId,
+ MissingTradePrevEventId,
+ TradeThreadRootMismatch,
+ TradeThreadPrevMismatch,
#[cfg(feature = "serde_json")]
InvalidWorkflowEvent {
error: TradeListingEnvelopeParseError,
@@ -463,6 +481,24 @@ impl core::fmt::Display for RadrootsTradeProjectionError {
write!(f, "non-order workflow message: {message_type:?}")
}
RadrootsTradeProjectionError::UnauthorizedActor => write!(f, "unauthorized actor"),
+ RadrootsTradeProjectionError::CounterpartyMismatch => {
+ write!(f, "counterparty pubkey mismatch")
+ }
+ RadrootsTradeProjectionError::MissingListingSnapshot => {
+ write!(f, "missing listing snapshot")
+ }
+ RadrootsTradeProjectionError::MissingTradeRootEventId => {
+ write!(f, "missing trade root event id")
+ }
+ RadrootsTradeProjectionError::MissingTradePrevEventId => {
+ write!(f, "missing trade previous event id")
+ }
+ RadrootsTradeProjectionError::TradeThreadRootMismatch => {
+ write!(f, "trade thread root mismatch")
+ }
+ RadrootsTradeProjectionError::TradeThreadPrevMismatch => {
+ write!(f, "trade thread previous event mismatch")
+ }
#[cfg(feature = "serde_json")]
RadrootsTradeProjectionError::InvalidWorkflowEvent { error } => write!(f, "{error}"),
}
@@ -630,16 +666,22 @@ impl RadrootsTradeOrderWorkflowProjection {
}
}
- fn from_order_request(order: &TradeOrder) -> Self {
- Self {
+ fn from_order_request(
+ message: &RadrootsTradeOrderWorkflowMessage,
+ order: &TradeOrder,
+ ) -> Result<Self, RadrootsTradeProjectionError> {
+ let listing_snapshot = require_listing_snapshot(message)?;
+ Ok(Self {
order_id: order.order_id.clone(),
listing_addr: order.listing_addr.clone(),
buyer_pubkey: order.buyer_pubkey.clone(),
seller_pubkey: order.seller_pubkey.clone(),
items: order.items.clone(),
requested_discounts: order.discounts.clone(),
- notes: order.notes.clone(),
status: TradeOrderStatus::Requested,
+ listing_snapshot: Some(listing_snapshot),
+ root_event_id: message.event_id.clone(),
+ last_event_id: message.event_id.clone(),
last_discount_request: None,
last_discount_offer: None,
accepted_discount: None,
@@ -660,7 +702,7 @@ impl RadrootsTradeOrderWorkflowProjection {
receipt_count: 0,
last_message_type: TradeListingMessageType::OrderRequest,
last_actor_pubkey: order.buyer_pubkey.clone(),
- }
+ })
}
}
@@ -668,10 +710,16 @@ impl RadrootsTradeOrderWorkflowMessage {
#[cfg(feature = "serde_json")]
pub fn from_event(event: &RadrootsNostrEvent) -> Result<Self, TradeListingEnvelopeParseError> {
let envelope = trade_listing_envelope_from_event::<TradeListingMessagePayload>(event)?;
+ let context = trade_event_context_from_tags(envelope.message_type, &event.tags)?;
Ok(Self {
+ event_id: event.id.clone(),
actor_pubkey: event.author.clone(),
+ counterparty_pubkey: context.counterparty_pubkey,
listing_addr: envelope.listing_addr,
order_id: envelope.order_id,
+ listing_event: context.listing_event,
+ root_event_id: context.root_event_id,
+ prev_event_id: context.prev_event_id,
payload: envelope.payload,
})
}
@@ -915,6 +963,8 @@ impl RadrootsTradeReadIndex {
let order_id = required_order_id(message)?;
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
let next_status = if response.accepted {
TradeOrderStatus::Accepted
} else {
@@ -928,15 +978,15 @@ impl RadrootsTradeReadIndex {
order.last_message_type = TradeListingMessageType::OrderResponse;
order.last_actor_pubkey = message.actor_pubkey.clone();
order.last_reason = response.reason.clone();
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::OrderRevision(revision) => {
let order_id = required_order_id(message)?;
- if revision.order_id != order_id {
- return Err(RadrootsTradeProjectionError::OrderIdMismatch);
- }
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Revised,
@@ -944,11 +994,13 @@ impl RadrootsTradeReadIndex {
for change in &revision.changes {
apply_order_change(&mut order.items, change)?;
}
+ order.listing_snapshot = Some(require_listing_snapshot(message)?);
order.status = TradeOrderStatus::Revised;
order.revision_count = order.revision_count.saturating_add(1);
order.last_message_type = TradeListingMessageType::OrderRevision;
order.last_actor_pubkey = message.actor_pubkey.clone();
- order.last_reason = revision.reason.clone();
+ order.last_reason = None;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::OrderRevisionAccept(response) => {
@@ -958,6 +1010,8 @@ impl RadrootsTradeReadIndex {
}
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Accepted,
@@ -966,6 +1020,7 @@ impl RadrootsTradeReadIndex {
order.last_message_type = TradeListingMessageType::OrderRevisionAccept;
order.last_actor_pubkey = message.actor_pubkey.clone();
order.last_reason = response.reason.clone();
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::OrderRevisionDecline(response) => {
@@ -975,6 +1030,8 @@ impl RadrootsTradeReadIndex {
}
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Declined,
@@ -983,19 +1040,15 @@ impl RadrootsTradeReadIndex {
order.last_message_type = TradeListingMessageType::OrderRevisionDecline;
order.last_actor_pubkey = message.actor_pubkey.clone();
order.last_reason = response.reason.clone();
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::Question(question) => {
let order_id = required_order_id(message)?;
- if question
- .order_id
- .as_deref()
- .is_some_and(|value| value != order_id)
- {
- return Err(RadrootsTradeProjectionError::OrderIdMismatch);
- }
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Questioned,
@@ -1004,19 +1057,16 @@ impl RadrootsTradeReadIndex {
order.question_count = order.question_count.saturating_add(1);
order.last_message_type = TradeListingMessageType::Question;
order.last_actor_pubkey = message.actor_pubkey.clone();
+ order.last_reason = Some(question.question_id.clone());
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::Answer(answer) => {
let order_id = required_order_id(message)?;
- if answer
- .order_id
- .as_deref()
- .is_some_and(|value| value != order_id)
- {
- return Err(RadrootsTradeProjectionError::OrderIdMismatch);
- }
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Requested,
@@ -1025,29 +1075,31 @@ impl RadrootsTradeReadIndex {
order.answer_count = order.answer_count.saturating_add(1);
order.last_message_type = TradeListingMessageType::Answer;
order.last_actor_pubkey = message.actor_pubkey.clone();
+ order.last_reason = Some(answer.question_id.clone());
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::DiscountRequest(request) => {
let order_id = required_order_id(message)?;
- if request.order_id != order_id {
- return Err(RadrootsTradeProjectionError::OrderIdMismatch);
- }
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
order.discount_request_count = order.discount_request_count.saturating_add(1);
order.last_discount_request = Some(request.value.clone());
+ order.listing_snapshot = Some(require_listing_snapshot(message)?);
order.last_message_type = TradeListingMessageType::DiscountRequest;
order.last_actor_pubkey = message.actor_pubkey.clone();
- order.last_reason = request.conditions.clone();
+ order.last_reason = None;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::DiscountOffer(offer) => {
let order_id = required_order_id(message)?;
- if offer.order_id != order_id {
- return Err(RadrootsTradeProjectionError::OrderIdMismatch);
- }
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Revised,
@@ -1055,15 +1107,19 @@ impl RadrootsTradeReadIndex {
order.status = TradeOrderStatus::Revised;
order.discount_offer_count = order.discount_offer_count.saturating_add(1);
order.last_discount_offer = Some(offer.value.clone());
+ order.listing_snapshot = Some(require_listing_snapshot(message)?);
order.last_message_type = TradeListingMessageType::DiscountOffer;
order.last_actor_pubkey = message.actor_pubkey.clone();
- order.last_reason = offer.conditions.clone();
+ order.last_reason = None;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::DiscountAccept(decision) => {
let order_id = required_order_id(message)?;
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
let TradeDiscountDecisionValue::Accepted(value) =
trade_discount_decision_value(decision)?
else {
@@ -1080,12 +1136,15 @@ impl RadrootsTradeReadIndex {
order.last_message_type = TradeListingMessageType::DiscountAccept;
order.last_actor_pubkey = message.actor_pubkey.clone();
order.last_reason = None;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::DiscountDecline(decision) => {
let order_id = required_order_id(message)?;
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
let TradeDiscountDecisionValue::Declined(reason) =
trade_discount_decision_value(decision)?
else {
@@ -1101,6 +1160,7 @@ impl RadrootsTradeReadIndex {
order.last_message_type = TradeListingMessageType::DiscountDecline;
order.last_actor_pubkey = message.actor_pubkey.clone();
order.last_reason = reason;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::Cancel(cancel) => {
@@ -1111,6 +1171,13 @@ impl RadrootsTradeReadIndex {
{
return Err(RadrootsTradeProjectionError::UnauthorizedActor);
}
+ let expected_counterparty = if order.buyer_pubkey == message.actor_pubkey {
+ &order.seller_pubkey
+ } else {
+ &order.buyer_pubkey
+ };
+ ensure_counterparty(expected_counterparty, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
radroots_trade_order_status_ensure_transition(
order.status.clone(),
TradeOrderStatus::Cancelled,
@@ -1120,12 +1187,15 @@ impl RadrootsTradeReadIndex {
order.last_message_type = TradeListingMessageType::Cancel;
order.last_actor_pubkey = message.actor_pubkey.clone();
order.last_reason = cancel.reason.clone();
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::FulfillmentUpdate(update) => {
let order_id = required_order_id(message)?;
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
if let Some(next_status) =
trade_order_status_for_fulfillment_update(&order.status, &update.status)?
{
@@ -1135,13 +1205,16 @@ impl RadrootsTradeReadIndex {
order.last_fulfillment_status = Some(update.status.clone());
order.last_message_type = TradeListingMessageType::FulfillmentUpdate;
order.last_actor_pubkey = message.actor_pubkey.clone();
- order.last_reason = update.notes.clone();
+ order.last_reason = None;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
TradeListingMessagePayload::Receipt(receipt) => {
let order_id = required_order_id(message)?;
let order = self.order_mut_checked(order_id, &message.listing_addr)?;
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
+ ensure_trade_chain(order, message)?;
if let Some(next_status) =
trade_order_status_for_receipt(&order.status, receipt.acknowledged)?
{
@@ -1152,7 +1225,8 @@ impl RadrootsTradeReadIndex {
order.receipt_at = Some(receipt.at);
order.last_message_type = TradeListingMessageType::Receipt;
order.last_actor_pubkey = message.actor_pubkey.clone();
- order.last_reason = receipt.note.clone();
+ order.last_reason = None;
+ order.last_event_id = message.event_id.clone();
Ok(order_id.to_string())
}
}
@@ -1174,8 +1248,9 @@ impl RadrootsTradeReadIndex {
return Err(RadrootsTradeProjectionError::ListingAddrMismatch);
}
ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?;
+ ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?;
radroots_trade_order_status_ensure_transition(
- order.status.clone(),
+ TradeOrderStatus::Draft,
TradeOrderStatus::Requested,
)?;
@@ -1191,7 +1266,7 @@ impl RadrootsTradeReadIndex {
self.orders.insert(
order.order_id.clone(),
- RadrootsTradeOrderWorkflowProjection::from_order_request(order),
+ RadrootsTradeOrderWorkflowProjection::from_order_request(message, order)?,
);
Ok(order.order_id.clone())
}
@@ -1366,6 +1441,15 @@ fn required_order_id(
.ok_or(RadrootsTradeProjectionError::MissingOrderId)
}
+fn require_listing_snapshot(
+ message: &RadrootsTradeOrderWorkflowMessage,
+) -> Result<RadrootsNostrEventPtr, RadrootsTradeProjectionError> {
+ message
+ .listing_event
+ .clone()
+ .ok_or(RadrootsTradeProjectionError::MissingListingSnapshot)
+}
+
fn ensure_actor(expected: &str, actual: &str) -> Result<(), RadrootsTradeProjectionError> {
if expected == actual {
Ok(())
@@ -1374,6 +1458,35 @@ fn ensure_actor(expected: &str, actual: &str) -> Result<(), RadrootsTradeProject
}
}
+fn ensure_counterparty(expected: &str, actual: &str) -> Result<(), RadrootsTradeProjectionError> {
+ if expected == actual {
+ Ok(())
+ } else {
+ Err(RadrootsTradeProjectionError::CounterpartyMismatch)
+ }
+}
+
+fn ensure_trade_chain(
+ order: &RadrootsTradeOrderWorkflowProjection,
+ message: &RadrootsTradeOrderWorkflowMessage,
+) -> Result<(), RadrootsTradeProjectionError> {
+ let root_event_id = message
+ .root_event_id
+ .as_deref()
+ .ok_or(RadrootsTradeProjectionError::MissingTradeRootEventId)?;
+ if root_event_id != order.root_event_id {
+ return Err(RadrootsTradeProjectionError::TradeThreadRootMismatch);
+ }
+ let prev_event_id = message
+ .prev_event_id
+ .as_deref()
+ .ok_or(RadrootsTradeProjectionError::MissingTradePrevEventId)?;
+ if prev_event_id != order.last_event_id {
+ return Err(RadrootsTradeProjectionError::TradeThreadPrevMismatch);
+ }
+ Ok(())
+}
+
fn apply_order_change(
items: &mut Vec<TradeOrderItem>,
change: &TradeOrderChange,
@@ -1663,6 +1776,8 @@ fn facet_counts_from_map(counts: BTreeMap<String, u32>) -> Vec<RadrootsTradeFace
#[cfg(test)]
mod tests {
+ use std::cell::RefCell;
+
use super::{
RadrootsTradeListingMarketStatus, RadrootsTradeListingQuery, RadrootsTradeListingSort,
RadrootsTradeListingSortField, RadrootsTradeOrderQuery, RadrootsTradeOrderSort,
@@ -1691,7 +1806,122 @@ mod tests {
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
RadrootsListingProduct, RadrootsListingStatus,
};
- use radroots_events::{RadrootsNostrEvent, kinds::KIND_LISTING};
+ use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr, kinds::KIND_LISTING};
+
+ #[derive(Clone, Debug)]
+ struct TestWorkflowChain {
+ buyer_pubkey: String,
+ seller_pubkey: String,
+ root_event_id: String,
+ last_event_id: String,
+ next_sequence: u32,
+ }
+
+ thread_local! {
+ static TEST_WORKFLOW_CHAINS: RefCell<std::collections::BTreeMap<String, TestWorkflowChain>> =
+ RefCell::new(std::collections::BTreeMap::new());
+ }
+
+ fn listing_snapshot(listing_addr: &str) -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: format!("snapshot:{listing_addr}"),
+ relays: None,
+ }
+ }
+
+ fn seller_pubkey_from_listing_addr(listing_addr: &str) -> String {
+ listing_addr
+ .split(':')
+ .nth(1)
+ .unwrap_or_default()
+ .to_string()
+ }
+
+ fn workflow_refs(
+ actor_pubkey: &str,
+ listing_addr: &str,
+ order_id: Option<&str>,
+ payload: &TradeListingMessagePayload,
+ ) -> (
+ String,
+ String,
+ Option<RadrootsNostrEventPtr>,
+ Option<String>,
+ Option<String>,
+ ) {
+ let message_type = payload.message_type();
+ let listing_event = message_type
+ .requires_listing_snapshot()
+ .then(|| listing_snapshot(listing_addr));
+ let default_seller = seller_pubkey_from_listing_addr(listing_addr);
+
+ match (message_type, order_id) {
+ (_, None) => (
+ format!("event:no-order:{}:{actor_pubkey}", message_type.kind()),
+ default_seller,
+ listing_event,
+ None,
+ None,
+ ),
+ (crate::listing::dvm::TradeListingMessageType::OrderRequest, Some(order_id)) => {
+ let order = match payload {
+ TradeListingMessagePayload::OrderRequest(order) => order,
+ _ => unreachable!("order request payload should match message type"),
+ };
+ let event_id = format!("{order_id}:request");
+ TEST_WORKFLOW_CHAINS.with(|chains| {
+ chains.borrow_mut().insert(
+ order_id.to_string(),
+ TestWorkflowChain {
+ buyer_pubkey: order.buyer_pubkey.clone(),
+ seller_pubkey: order.seller_pubkey.clone(),
+ root_event_id: event_id.clone(),
+ last_event_id: event_id.clone(),
+ next_sequence: 1,
+ },
+ );
+ });
+ (
+ event_id,
+ order.seller_pubkey.clone(),
+ listing_event,
+ None,
+ None,
+ )
+ }
+ (_, Some(order_id)) => TEST_WORKFLOW_CHAINS.with(|chains| {
+ let mut chains = chains.borrow_mut();
+ let chain =
+ chains
+ .entry(order_id.to_string())
+ .or_insert_with(|| TestWorkflowChain {
+ buyer_pubkey: String::from("buyer-pubkey"),
+ seller_pubkey: default_seller.clone(),
+ root_event_id: format!("{order_id}:root"),
+ last_event_id: format!("{order_id}:root"),
+ next_sequence: 1,
+ });
+ let event_id =
+ format!("{order_id}:{}:{}", message_type.kind(), chain.next_sequence);
+ chain.next_sequence += 1;
+ let counterparty_pubkey = if actor_pubkey == chain.seller_pubkey {
+ chain.buyer_pubkey.clone()
+ } else {
+ chain.seller_pubkey.clone()
+ };
+ let prev_event_id = chain.last_event_id.clone();
+ let root_event_id = chain.root_event_id.clone();
+ chain.last_event_id = event_id.clone();
+ (
+ event_id,
+ counterparty_pubkey,
+ listing_event,
+ Some(root_event_id),
+ Some(prev_event_id),
+ )
+ }),
+ }
+ }
fn base_listing() -> RadrootsListing {
RadrootsListing {
@@ -1795,8 +2025,6 @@ mod tests {
discounts: Some(vec![radroots_core::RadrootsCoreDiscountValue::Percent(
RadrootsCorePercent::new(RadrootsCoreDecimal::from(10u32)),
)]),
- notes: Some("deliver friday".into()),
- status: TradeOrderStatus::Requested,
}
}
@@ -1883,8 +2111,6 @@ mod tests {
},
],
discounts: None,
- notes: Some("expedite".into()),
- status: TradeOrderStatus::Requested,
}
}
@@ -1894,10 +2120,17 @@ mod tests {
order_id: Option<&str>,
payload: TradeListingMessagePayload,
) -> RadrootsTradeOrderWorkflowMessage {
+ let (event_id, counterparty_pubkey, listing_event, root_event_id, prev_event_id) =
+ workflow_refs(actor_pubkey, listing_addr, order_id, &payload);
RadrootsTradeOrderWorkflowMessage {
+ event_id,
actor_pubkey: actor_pubkey.into(),
+ counterparty_pubkey,
listing_addr: listing_addr.into(),
order_id: order_id.map(str::to_string),
+ listing_event,
+ root_event_id,
+ prev_event_id,
payload,
}
}
@@ -1922,11 +2155,16 @@ mod tests {
order_id: Option<&str>,
payload: &TradeListingMessagePayload,
) -> RadrootsNostrEvent {
+ let (_, _, listing_event, root_event_id, prev_event_id) =
+ workflow_refs(actor_pubkey, listing_addr, order_id, payload);
let built = trade_listing_envelope_event_build(
recipient_pubkey,
message_type,
listing_addr.to_string(),
order_id.map(str::to_string),
+ listing_event.as_ref(),
+ root_event_id.as_deref(),
+ prev_event_id.as_deref(),
payload,
)
.expect("trade workflow event");
@@ -2045,9 +2283,6 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate {
status: TradeFulfillmentStatus::Delivered,
- tracking: Some("track-1".into()),
- eta: None,
- notes: Some("left at dock".into()),
}),
))
.expect("fulfillment");
@@ -2066,7 +2301,6 @@ mod tests {
TradeListingMessagePayload::Receipt(TradeReceipt {
acknowledged: true,
at: 1_700_000_000,
- note: Some("received".into()),
}),
))
.expect("receipt");
@@ -2112,9 +2346,6 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate {
status: TradeFulfillmentStatus::Shipped,
- tracking: Some("track-1".into()),
- eta: None,
- notes: Some("left warehouse".into()),
}),
))
.expect("fulfillment update");
@@ -2164,9 +2395,6 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate {
status: TradeFulfillmentStatus::Delivered,
- tracking: Some("track-1".into()),
- eta: None,
- notes: Some("left at dock".into()),
}),
))
.expect("fulfilled");
@@ -2178,7 +2406,6 @@ mod tests {
TradeListingMessagePayload::Receipt(TradeReceipt {
acknowledged: false,
at: 1_700_000_000,
- note: Some("received pending inspection".into()),
}),
))
.expect("receipt");
@@ -2212,9 +2439,6 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::Question(TradeQuestion {
question_id: "q-1".into(),
- order_id: Some("order-1".into()),
- listing_addr: None,
- question_text: "can you pack separately?".into(),
}),
))
.expect("question");
@@ -2225,9 +2449,6 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::Answer(TradeAnswer {
question_id: "q-1".into(),
- order_id: Some("order-1".into()),
- listing_addr: None,
- answer_text: "yes".into(),
}),
))
.expect("answer");
@@ -2243,7 +2464,6 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::OrderRevision(TradeOrderRevision {
revision_id: "rev-1".into(),
- order_id: "order-1".into(),
changes: vec![
TradeOrderChange::BinCount {
item_index: 0,
@@ -2256,7 +2476,6 @@ mod tests {
},
},
],
- reason: Some("limited stock".into()),
}),
))
.expect("order revision");
@@ -2286,11 +2505,9 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::DiscountRequest(TradeDiscountRequest {
discount_id: "disc-1".into(),
- order_id: "order-1".into(),
value: radroots_core::RadrootsCoreDiscountValue::Percent(
RadrootsCorePercent::new(RadrootsCoreDecimal::from(15u32)),
),
- conditions: Some("volume".into()),
}),
))
.expect("discount request");
@@ -2301,11 +2518,9 @@ mod tests {
Some("order-1"),
TradeListingMessagePayload::DiscountOffer(TradeDiscountOffer {
discount_id: "disc-1".into(),
- order_id: "order-1".into(),
value: radroots_core::RadrootsCoreDiscountValue::Percent(
RadrootsCorePercent::new(RadrootsCoreDecimal::from(12u32)),
),
- conditions: Some("counter".into()),
}),
))
.expect("discount offer");
@@ -2373,9 +2588,6 @@ mod tests {
None,
TradeListingMessagePayload::Question(TradeQuestion {
question_id: "q-1".into(),
- order_id: Some("order-1".into()),
- listing_addr: None,
- question_text: "hello".into(),
}),
))
.expect_err("missing order id should fail");
@@ -2412,24 +2624,44 @@ mod tests {
RadrootsTradeProjectionError::MissingPrimaryBin("missing".into())
);
- let err = index
+ let order = index
.apply_workflow_message(&message(
"buyer-pubkey",
"30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg",
Some("order-1"),
- TradeListingMessagePayload::OrderRequest(TradeOrder {
- status: TradeOrderStatus::Accepted,
- ..base_order()
- }),
+ TradeListingMessagePayload::OrderRequest(base_order()),
))
- .expect_err("non-requested order request should fail");
+ .expect("order request");
assert_eq!(
- err,
- RadrootsTradeProjectionError::InvalidTransition {
- from: TradeOrderStatus::Accepted,
- to: TradeOrderStatus::Requested,
- }
+ order.status,
+ TradeOrderStatus::Requested,
+ "canonical helper should still create a requested order"
);
+
+ let err = index
+ .apply_workflow_message(&RadrootsTradeOrderWorkflowMessage {
+ event_id: "missing-snapshot".into(),
+ actor_pubkey: "buyer-pubkey".into(),
+ counterparty_pubkey: "seller-pubkey".into(),
+ listing_addr: "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg".into(),
+ order_id: Some("order-2".into()),
+ listing_event: None,
+ root_event_id: None,
+ prev_event_id: None,
+ payload: TradeListingMessagePayload::OrderRequest(TradeOrder {
+ order_id: "order-2".into(),
+ listing_addr: "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg".into(),
+ buyer_pubkey: "buyer-pubkey".into(),
+ seller_pubkey: "seller-pubkey".into(),
+ items: vec![TradeOrderItem {
+ bin_id: "bin-1".into(),
+ bin_count: 1,
+ }],
+ discounts: None,
+ }),
+ })
+ .expect_err("order request without snapshot should fail");
+ assert_eq!(err, RadrootsTradeProjectionError::MissingListingSnapshot);
}
#[test]
@@ -2500,9 +2732,6 @@ mod tests {
Some("order-2"),
TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate {
status: TradeFulfillmentStatus::Delivered,
- tracking: None,
- eta: None,
- notes: Some("shipped".into()),
}),
))
.expect("order fulfilled");
@@ -2514,7 +2743,6 @@ mod tests {
TradeListingMessagePayload::Receipt(TradeReceipt {
acknowledged: true,
at: 1_700_000_010,
- note: Some("received".into()),
}),
))
.expect("order receipt");
@@ -2613,9 +2841,6 @@ mod tests {
Some("order-2"),
TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate {
status: TradeFulfillmentStatus::Delivered,
- tracking: Some("track-2".into()),
- eta: None,
- notes: Some("in transit".into()),
}),
))
.expect("fulfilled");
@@ -2627,7 +2852,6 @@ mod tests {
TradeListingMessagePayload::Receipt(TradeReceipt {
acknowledged: true,
at: 1_700_000_020,
- note: Some("all good".into()),
}),
))
.expect("completed");
@@ -2663,7 +2887,7 @@ mod tests {
assert_eq!(summaries[0].item_count, 2);
assert_eq!(summaries[0].total_bin_count, 4);
assert!(!summaries[0].has_requested_discounts);
- assert_eq!(summaries[0].last_reason.as_deref(), Some("all good"));
+ assert_eq!(summaries[0].last_reason, None);
assert_eq!(summaries[1].order_id, "order-1");
assert!(summaries[1].has_requested_discounts);
diff --git a/crates/trade/src/listing/tags.rs b/crates/trade/src/listing/tags.rs
@@ -1,13 +1,21 @@
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
-use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT};
-use radroots_events_codec::job::error::JobParseError;
+use radroots_events::{
+ RadrootsNostrEventPtr,
+ tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT},
+};
+use radroots_events_codec::{
+ error::{EventEncodeError, EventParseError},
+ job::error::JobParseError,
+};
+
+pub const TAG_LISTING_EVENT: &str = "listing_event";
#[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(name.to_string());
tag.push(value.into());
tags.push(tag);
}
@@ -17,19 +25,142 @@ pub fn trade_listing_dvm_tags<P, A, D>(
recipient_pubkey: P,
listing_addr: A,
order_id: Option<D>,
-) -> Vec<Vec<String>>
+ listing_event: Option<&RadrootsNostrEventPtr>,
+ root_event_id: Option<&str>,
+ prev_event_id: Option<&str>,
+) -> Result<Vec<Vec<String>>, EventEncodeError>
where
P: Into<String>,
A: Into<String>,
D: Into<String>,
{
- let mut tags = Vec::with_capacity(2 + usize::from(order_id.is_some()));
+ let recipient_pubkey = recipient_pubkey.into();
+ if recipient_pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("recipient_pubkey"));
+ }
+ let listing_addr = listing_addr.into();
+ if listing_addr.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_addr"));
+ }
+ let mut tags = Vec::with_capacity(
+ 2 + usize::from(order_id.is_some())
+ + usize::from(listing_event.is_some())
+ + usize::from(root_event_id.is_some())
+ + usize::from(prev_event_id.is_some()),
+ );
push_tag(&mut tags, "p", recipient_pubkey);
push_tag(&mut tags, "a", listing_addr);
if let Some(order_id) = order_id {
+ let order_id = order_id.into();
+ if order_id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("order_id"));
+ }
push_tag(&mut tags, TAG_D, order_id);
}
- tags
+ if let Some(listing_event) = listing_event {
+ if listing_event.id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_event.id"));
+ }
+ let mut tag = vec![TAG_LISTING_EVENT.to_string(), listing_event.id.clone()];
+ if let Some(relay) = &listing_event.relays {
+ if relay.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("listing_event.relays"));
+ }
+ tag.push(relay.clone());
+ }
+ tags.push(tag);
+ }
+ if let Some(root_event_id) = root_event_id {
+ if root_event_id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("root_event_id"));
+ }
+ push_tag(&mut tags, TAG_E_ROOT, root_event_id);
+ }
+ if let Some(prev_event_id) = prev_event_id {
+ if prev_event_id.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("prev_event_id"));
+ }
+ push_tag(&mut tags, TAG_E_PREV, prev_event_id);
+ }
+ Ok(tags)
+}
+
+#[inline]
+pub fn parse_trade_listing_counterparty_tag(
+ tags: &[Vec<String>],
+) -> Result<String, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some("p"))
+ .ok_or(EventParseError::MissingTag("p"))?;
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag("p"));
+ }
+ Ok(value.clone())
+}
+
+#[inline]
+pub fn parse_trade_listing_event_tag(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> {
+ let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_LISTING_EVENT))
+ else {
+ return Ok(None);
+ };
+ let id = tag
+ .get(1)
+ .ok_or(EventParseError::InvalidTag(TAG_LISTING_EVENT))?;
+ if id.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_LISTING_EVENT));
+ }
+ let relays = match tag.get(2) {
+ Some(value) if value.trim().is_empty() => {
+ return Err(EventParseError::InvalidTag(TAG_LISTING_EVENT));
+ }
+ Some(value) => Some(value.clone()),
+ None => None,
+ };
+ Ok(Some(RadrootsNostrEventPtr {
+ id: id.clone(),
+ relays,
+ }))
+}
+
+#[inline]
+pub fn parse_trade_listing_root_tag(
+ tags: &[Vec<String>],
+) -> Result<Option<String>, EventParseError> {
+ let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT))
+ else {
+ return Ok(None);
+ };
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_ROOT))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_E_ROOT));
+ }
+ Ok(Some(value.clone()))
+}
+
+#[inline]
+pub fn parse_trade_listing_prev_tag(
+ tags: &[Vec<String>],
+) -> Result<Option<String>, EventParseError> {
+ let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_PREV))
+ else {
+ return Ok(None);
+ };
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_PREV))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_E_PREV));
+ }
+ Ok(Some(value.clone()))
}
#[inline]
@@ -98,10 +229,15 @@ pub fn validate_trade_listing_chain(tags: &[Vec<String>]) -> Result<(), JobParse
#[cfg(test)]
mod tests {
use super::{
- push_trade_listing_chain_tags, trade_listing_dvm_tags, validate_trade_listing_chain,
+ TAG_LISTING_EVENT, parse_trade_listing_counterparty_tag, parse_trade_listing_event_tag,
+ parse_trade_listing_prev_tag, parse_trade_listing_root_tag, push_trade_listing_chain_tags,
+ trade_listing_dvm_tags, validate_trade_listing_chain,
+ };
+ use radroots_events::{
+ RadrootsNostrEventPtr,
+ kinds::KIND_LISTING,
+ tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT},
};
- use radroots_events::kinds::KIND_LISTING;
- use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT};
#[test]
fn validate_trade_listing_chain_ok() {
@@ -123,105 +259,55 @@ mod tests {
}
#[test]
- fn validate_trade_listing_chain_rejects_empty_root_value() {
- let tags = vec![
- vec![TAG_E_ROOT.into(), " ".into()],
- vec![TAG_D.into(), "trade".into()],
- ];
- let err = validate_trade_listing_chain(&tags).unwrap_err();
- assert_eq!(
- err.to_string(),
- format!("invalid tag structure for '{TAG_E_ROOT}'")
- );
+ fn trade_listing_dvm_tags_builds_expected_tags() {
+ let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let tags = trade_listing_dvm_tags(
+ "pubkey",
+ &listing_addr,
+ Some("order-1"),
+ Some(&RadrootsNostrEventPtr {
+ id: "listing-snapshot".into(),
+ relays: None,
+ }),
+ Some("root"),
+ Some("prev"),
+ )
+ .expect("trade listing tags");
+ assert_eq!(tags[0], vec!["p".to_string(), "pubkey".to_string()]);
+ assert_eq!(tags[1], vec!["a".to_string(), listing_addr]);
+ assert!(tags.iter().any(|tag| tag[0] == TAG_LISTING_EVENT));
}
#[test]
- fn validate_trade_listing_chain_rejects_root_without_value() {
- let tags = vec![vec![TAG_E_ROOT.into()], vec![TAG_D.into(), "trade".into()]];
- let err = validate_trade_listing_chain(&tags).unwrap_err();
+ fn trade_listing_tag_parsers_extract_context() {
+ let tags = vec![
+ vec!["p".into(), "counterparty".into()],
+ vec![TAG_LISTING_EVENT.into(), "snapshot".into()],
+ vec![TAG_E_ROOT.into(), "root".into()],
+ vec![TAG_E_PREV.into(), "prev".into()],
+ ];
assert_eq!(
- err.to_string(),
- format!("invalid tag structure for '{TAG_E_ROOT}'")
+ parse_trade_listing_counterparty_tag(&tags).expect("counterparty"),
+ "counterparty"
);
- }
-
- #[test]
- fn validate_trade_listing_chain_rejects_missing_trade_id() {
- let tags = vec![vec![TAG_E_ROOT.into(), "root".into()]];
- let err = validate_trade_listing_chain(&tags).unwrap_err();
assert_eq!(
- err.to_string(),
- format!("missing required chain tag: {TAG_D}")
+ parse_trade_listing_event_tag(&tags).expect("snapshot"),
+ Some(RadrootsNostrEventPtr {
+ id: "snapshot".into(),
+ relays: None,
+ })
);
- }
-
- #[test]
- fn validate_trade_listing_chain_rejects_empty_trade_id() {
- let tags = vec![
- vec![TAG_E_ROOT.into(), "root".into()],
- vec![TAG_D.into(), " ".into()],
- ];
- let err = validate_trade_listing_chain(&tags).unwrap_err();
assert_eq!(
- err.to_string(),
- format!("invalid tag structure for '{TAG_D}'")
+ parse_trade_listing_root_tag(&tags).expect("root"),
+ Some("root".into())
);
- }
-
- #[test]
- fn validate_trade_listing_chain_rejects_trade_id_without_value() {
- let tags = vec![vec![TAG_E_ROOT.into(), "root".into()], vec![TAG_D.into()]];
- let err = validate_trade_listing_chain(&tags).unwrap_err();
assert_eq!(
- err.to_string(),
- format!("invalid tag structure for '{TAG_D}'")
+ parse_trade_listing_prev_tag(&tags).expect("prev"),
+ Some("prev".into())
);
}
#[test]
- fn validate_trade_listing_chain_accepts_trade_id_before_root() {
- let tags = vec![
- vec![TAG_D.into(), "trade".into()],
- vec!["x".into(), "ignore".into()],
- vec![TAG_E_ROOT.into(), "root".into()],
- ];
- assert!(validate_trade_listing_chain(&tags).is_ok());
- }
-
- #[test]
- fn validate_trade_listing_chain_ignores_unknown_single_key_tag() {
- let tags = vec![
- vec!["x".into()],
- vec![TAG_E_ROOT.into(), "root".into()],
- vec![TAG_D.into(), "trade".into()],
- ];
- assert!(validate_trade_listing_chain(&tags).is_ok());
- }
-
- #[test]
- fn trade_listing_dvm_tags_builds_expected_tags() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
- let tags = trade_listing_dvm_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.clone()],
- vec![String::from(TAG_D), String::from("order-1")],
- ];
- assert_eq!(tags, expected);
- }
-
- #[test]
- fn trade_listing_dvm_tags_omit_order_id_when_missing() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
- let tags = trade_listing_dvm_tags("pubkey", &listing_addr, None::<String>);
- let expected: Vec<Vec<String>> = vec![
- vec![String::from("p"), String::from("pubkey")],
- vec![String::from("a"), listing_addr.clone()],
- ];
- assert_eq!(tags, expected);
- }
-
- #[test]
fn push_trade_listing_chain_tags_appends_optional_fields() {
let mut tags = vec![vec![String::from("x"), String::from("seed")]];
push_trade_listing_chain_tags(
@@ -241,20 +327,4 @@ mod tests {
]
);
}
-
- #[test]
- fn push_trade_listing_chain_tags_skips_missing_optional_fields() {
- let mut tags = Vec::new();
- push_trade_listing_chain_tags(
- &mut tags,
- "root-id",
- Option::<String>::None,
- Option::<String>::None,
- );
-
- assert_eq!(
- tags,
- vec![vec![String::from(TAG_E_ROOT), String::from("root-id")]]
- );
- }
}