commit e642ae75a82aeca66379a8134ccb3db1d3f3e615
parent e91c42c520420e0da20728682073e73cf480792b
Author: triesap <tyson@radroots.org>
Date: Fri, 27 Mar 2026 23:56:19 +0000
trade: add canonical read-index event adapters
Diffstat:
2 files changed, 511 insertions(+), 5 deletions(-)
diff --git a/crates/trade/src/listing/dvm.rs b/crates/trade/src/listing/dvm.rs
@@ -3,8 +3,12 @@
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
+#[cfg(feature = "serde_json")]
+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 serde::de::DeserializeOwned;
#[cfg(feature = "ts-rs")]
use ts_rs::TS;
@@ -16,7 +20,7 @@ use crate::listing::kinds::{
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_RECEIPT_REQ, KIND_TRADE_LISTING_VALIDATE_REQ,
- KIND_TRADE_LISTING_VALIDATE_RES,
+ KIND_TRADE_LISTING_VALIDATE_RES, is_trade_listing_kind,
};
use crate::listing::order::{
TradeAnswer, TradeDiscountDecision, TradeDiscountOffer, TradeDiscountRequest,
@@ -65,6 +69,34 @@ pub enum TradeListingMessageType {
impl TradeListingMessageType {
#[inline]
+ pub const fn from_kind(kind: u16) -> Option<Self> {
+ match kind {
+ KIND_TRADE_LISTING_VALIDATE_REQ => {
+ Some(TradeListingMessageType::ListingValidateRequest)
+ }
+ KIND_TRADE_LISTING_VALIDATE_RES => Some(TradeListingMessageType::ListingValidateResult),
+ KIND_TRADE_LISTING_ORDER_REQ => Some(TradeListingMessageType::OrderRequest),
+ KIND_TRADE_LISTING_ORDER_RES => Some(TradeListingMessageType::OrderResponse),
+ KIND_TRADE_LISTING_ORDER_REVISION_REQ => Some(TradeListingMessageType::OrderRevision),
+ KIND_TRADE_LISTING_ORDER_REVISION_RES => None,
+ KIND_TRADE_LISTING_QUESTION_REQ => Some(TradeListingMessageType::Question),
+ KIND_TRADE_LISTING_ANSWER_RES => Some(TradeListingMessageType::Answer),
+ KIND_TRADE_LISTING_DISCOUNT_REQ => Some(TradeListingMessageType::DiscountRequest),
+ KIND_TRADE_LISTING_DISCOUNT_OFFER_RES => Some(TradeListingMessageType::DiscountOffer),
+ KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ => Some(TradeListingMessageType::DiscountAccept),
+ KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ => {
+ Some(TradeListingMessageType::DiscountDecline)
+ }
+ KIND_TRADE_LISTING_CANCEL_REQ => Some(TradeListingMessageType::Cancel),
+ KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ => {
+ Some(TradeListingMessageType::FulfillmentUpdate)
+ }
+ KIND_TRADE_LISTING_RECEIPT_REQ => Some(TradeListingMessageType::Receipt),
+ _ => None,
+ }
+ }
+
+ #[inline]
pub const fn kind(self) -> u16 {
match self {
TradeListingMessageType::ListingValidateRequest => KIND_TRADE_LISTING_VALIDATE_REQ,
@@ -232,6 +264,70 @@ impl core::fmt::Display for TradeListingEnvelopeError {
#[cfg(feature = "std")]
impl std::error::Error for TradeListingEnvelopeError {}
+#[cfg(feature = "serde_json")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum TradeListingEnvelopeParseError {
+ InvalidKind(u32),
+ InvalidJson,
+ InvalidEnvelope(TradeListingEnvelopeError),
+ MessageTypeKindMismatch {
+ event_kind: u32,
+ message_type: TradeListingMessageType,
+ },
+ MissingTag(&'static str),
+ InvalidTag(&'static str),
+ ListingAddrTagMismatch,
+ OrderIdTagMismatch,
+ InvalidListingAddr(TradeListingAddressError),
+}
+
+#[cfg(feature = "serde_json")]
+impl core::fmt::Display for TradeListingEnvelopeParseError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ TradeListingEnvelopeParseError::InvalidKind(kind) => {
+ write!(f, "invalid trade listing event kind: {kind}")
+ }
+ TradeListingEnvelopeParseError::InvalidJson => {
+ write!(f, "invalid trade listing envelope json")
+ }
+ TradeListingEnvelopeParseError::InvalidEnvelope(error) => write!(f, "{error}"),
+ TradeListingEnvelopeParseError::MessageTypeKindMismatch {
+ event_kind,
+ message_type,
+ } => write!(
+ f,
+ "trade listing envelope type {message_type:?} does not match event kind {event_kind}"
+ ),
+ TradeListingEnvelopeParseError::MissingTag(tag) => {
+ write!(f, "missing required trade listing tag: {tag}")
+ }
+ TradeListingEnvelopeParseError::InvalidTag(tag) => {
+ write!(f, "invalid trade listing tag: {tag}")
+ }
+ TradeListingEnvelopeParseError::ListingAddrTagMismatch => {
+ write!(f, "trade listing address tag does not match envelope")
+ }
+ TradeListingEnvelopeParseError::OrderIdTagMismatch => {
+ write!(f, "trade order id tag does not match envelope")
+ }
+ TradeListingEnvelopeParseError::InvalidListingAddr(error) => write!(f, "{error}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+#[cfg(feature = "serde_json")]
+impl std::error::Error for TradeListingEnvelopeParseError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ TradeListingEnvelopeParseError::InvalidEnvelope(error) => Some(error),
+ TradeListingEnvelopeParseError::InvalidListingAddr(error) => Some(error),
+ _ => None,
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TradeListingAddress {
pub kind: u16,
@@ -293,6 +389,66 @@ impl core::fmt::Display for TradeListingAddressError {
#[cfg(feature = "std")]
impl std::error::Error for TradeListingAddressError {}
+#[cfg(feature = "serde_json")]
+fn required_tag_value<'a>(
+ tags: &'a [Vec<String>],
+ key: &'static str,
+) -> Result<&'a str, TradeListingEnvelopeParseError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+ .ok_or(TradeListingEnvelopeParseError::MissingTag(key))?;
+ let value = tag
+ .get(1)
+ .map(|value| value.as_str())
+ .ok_or(TradeListingEnvelopeParseError::InvalidTag(key))?;
+ if value.trim().is_empty() {
+ return Err(TradeListingEnvelopeParseError::InvalidTag(key));
+ }
+ Ok(value)
+}
+
+#[cfg(feature = "serde_json")]
+impl<T> TradeListingEnvelope<T>
+where
+ T: DeserializeOwned,
+{
+ pub fn from_event(event: &RadrootsNostrEvent) -> Result<Self, TradeListingEnvelopeParseError> {
+ let event_kind = u16::try_from(event.kind)
+ .map_err(|_| TradeListingEnvelopeParseError::InvalidKind(event.kind))?;
+ if !is_trade_listing_kind(event_kind) {
+ return Err(TradeListingEnvelopeParseError::InvalidKind(event.kind));
+ }
+ let envelope = serde_json::from_str::<Self>(&event.content)
+ .map_err(|_| TradeListingEnvelopeParseError::InvalidJson)?;
+ envelope
+ .validate()
+ .map_err(TradeListingEnvelopeParseError::InvalidEnvelope)?;
+ if envelope.message_type.kind() != event_kind {
+ return Err(TradeListingEnvelopeParseError::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(TradeListingEnvelopeParseError::ListingAddrTagMismatch);
+ }
+ TradeListingAddress::parse(&envelope.listing_addr)
+ .map_err(TradeListingEnvelopeParseError::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(TradeListingEnvelopeParseError::OrderIdTagMismatch);
+ }
+ }
+
+ Ok(envelope)
+ }
+}
+
#[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))]
@@ -372,10 +528,16 @@ pub enum TradeListingMessagePayload {
mod tests {
use super::{
TradeListingAddress, TradeListingAddressError, TradeListingEnvelope,
- TradeListingEnvelopeError, TradeListingMessageType, TradeListingValidateRequest,
+ TradeListingEnvelopeError, TradeListingEnvelopeParseError, TradeListingMessagePayload,
+ TradeListingMessageType, TradeListingValidateRequest, trade_listing_envelope_event_build,
};
+ #[cfg(feature = "serde_json")]
+ use radroots_events::RadrootsNostrEvent;
use radroots_events::kinds::KIND_LISTING;
+ #[cfg(feature = "serde_json")]
+ use crate::listing::order::{TradeOrder, TradeOrderItem, TradeOrderStatus};
+
#[test]
fn envelope_requires_listing_addr() {
let env = TradeListingEnvelope::new(
@@ -479,6 +641,36 @@ mod tests {
}
#[test]
+ fn message_type_from_kind_roundtrips_supported_variants() {
+ for message_type in [
+ TradeListingMessageType::ListingValidateRequest,
+ TradeListingMessageType::ListingValidateResult,
+ TradeListingMessageType::OrderRequest,
+ TradeListingMessageType::OrderResponse,
+ TradeListingMessageType::OrderRevision,
+ TradeListingMessageType::Question,
+ TradeListingMessageType::Answer,
+ TradeListingMessageType::DiscountRequest,
+ TradeListingMessageType::DiscountOffer,
+ TradeListingMessageType::DiscountAccept,
+ TradeListingMessageType::DiscountDecline,
+ TradeListingMessageType::Cancel,
+ TradeListingMessageType::FulfillmentUpdate,
+ TradeListingMessageType::Receipt,
+ ] {
+ assert_eq!(
+ TradeListingMessageType::from_kind(message_type.kind()),
+ Some(message_type)
+ );
+ }
+ assert_eq!(
+ TradeListingMessageType::from_kind(super::KIND_TRADE_LISTING_ORDER_REVISION_RES),
+ None
+ );
+ assert_eq!(TradeListingMessageType::from_kind(5000), None);
+ }
+
+ #[test]
fn envelope_validate_rejects_invalid_version() {
let mut env = TradeListingEnvelope::new(
TradeListingMessageType::ListingValidateRequest,
@@ -573,6 +765,51 @@ mod tests {
}
#[cfg(feature = "serde_json")]
+ fn base_order() -> TradeOrder {
+ TradeOrder {
+ order_id: "order-1".into(),
+ listing_addr: format!("{KIND_LISTING}:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg"),
+ buyer_pubkey: "buyer-pubkey".into(),
+ seller_pubkey: "seller-pubkey".into(),
+ items: vec![TradeOrderItem {
+ bin_id: "bin-1".into(),
+ bin_count: 2,
+ }],
+ discounts: None,
+ notes: Some("deliver friday".into()),
+ status: TradeOrderStatus::Draft,
+ }
+ }
+
+ #[cfg(feature = "serde_json")]
+ fn base_event(
+ actor_pubkey: &str,
+ recipient_pubkey: &str,
+ message_type: TradeListingMessageType,
+ listing_addr: &str,
+ order_id: Option<&str>,
+ payload: &TradeListingMessagePayload,
+ ) -> RadrootsNostrEvent {
+ let built = trade_listing_envelope_event_build(
+ recipient_pubkey,
+ message_type,
+ listing_addr.to_string(),
+ order_id.map(str::to_string),
+ payload,
+ )
+ .expect("canonical envelope event");
+ RadrootsNostrEvent {
+ id: "event-id".into(),
+ author: actor_pubkey.into(),
+ created_at: 1_700_000_000,
+ kind: u32::from(built.kind),
+ tags: built.tags,
+ content: built.content,
+ sig: "sig".into(),
+ }
+ }
+
+ #[cfg(feature = "serde_json")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
struct EnvelopePayload {
fail: bool,
@@ -666,4 +903,87 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("intentional"));
}
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn envelope_from_event_parses_canonical_order_request() {
+ let payload = TradeListingMessagePayload::OrderRequest(base_order());
+ let event = base_event(
+ "buyer-pubkey",
+ "seller-pubkey",
+ TradeListingMessageType::OrderRequest,
+ &format!("{KIND_LISTING}:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg"),
+ Some("order-1"),
+ &payload,
+ );
+
+ let envelope =
+ TradeListingEnvelope::<TradeListingMessagePayload>::from_event(&event).unwrap();
+ assert_eq!(envelope.message_type, TradeListingMessageType::OrderRequest);
+ assert_eq!(envelope.order_id.as_deref(), Some("order-1"));
+ assert_eq!(envelope.payload, payload);
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn envelope_from_event_rejects_kind_mismatch() {
+ let payload = TradeListingMessagePayload::OrderRequest(base_order());
+ let mut event = base_event(
+ "buyer-pubkey",
+ "seller-pubkey",
+ TradeListingMessageType::OrderRequest,
+ &format!("{KIND_LISTING}:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg"),
+ Some("order-1"),
+ &payload,
+ );
+ event.kind = u32::from(TradeListingMessageType::OrderResponse.kind());
+
+ let err = TradeListingEnvelope::<TradeListingMessagePayload>::from_event(&event)
+ .expect_err("kind mismatch should fail");
+ assert_eq!(
+ err,
+ TradeListingEnvelopeParseError::MessageTypeKindMismatch {
+ event_kind: u32::from(TradeListingMessageType::OrderResponse.kind()),
+ message_type: TradeListingMessageType::OrderRequest,
+ }
+ );
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn envelope_from_event_rejects_listing_addr_tag_mismatch() {
+ let payload = TradeListingMessagePayload::OrderRequest(base_order());
+ let mut event = base_event(
+ "buyer-pubkey",
+ "seller-pubkey",
+ TradeListingMessageType::OrderRequest,
+ &format!("{KIND_LISTING}:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg"),
+ Some("order-1"),
+ &payload,
+ );
+ event.tags[1][1] = format!("{KIND_LISTING}:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw");
+
+ let err = TradeListingEnvelope::<TradeListingMessagePayload>::from_event(&event)
+ .expect_err("listing addr mismatch should fail");
+ assert_eq!(err, TradeListingEnvelopeParseError::ListingAddrTagMismatch);
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn envelope_from_event_rejects_order_id_tag_mismatch() {
+ let payload = TradeListingMessagePayload::OrderRequest(base_order());
+ let mut event = base_event(
+ "buyer-pubkey",
+ "seller-pubkey",
+ TradeListingMessageType::OrderRequest,
+ &format!("{KIND_LISTING}:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg"),
+ Some("order-1"),
+ &payload,
+ );
+ event.tags[2][1] = "order-2".into();
+
+ let err = TradeListingEnvelope::<TradeListingMessagePayload>::from_event(&event)
+ .expect_err("order id mismatch should fail");
+ assert_eq!(err, TradeListingEnvelopeParseError::OrderIdTagMismatch);
+ }
}
diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs
@@ -7,6 +7,7 @@ use std::collections::BTreeMap;
use radroots_core::{RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue};
use radroots_events::{
+ RadrootsNostrEvent,
kinds::KIND_LISTING,
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
@@ -20,7 +21,11 @@ use radroots_events::{
use ts_rs::TS;
use crate::listing::{
- dvm::{TradeListingMessagePayload, TradeListingMessageType},
+ codec::{TradeListingParseError, listing_from_event_parts},
+ dvm::{
+ TradeListingEnvelope, TradeListingEnvelopeParseError, TradeListingMessagePayload,
+ TradeListingMessageType,
+ },
model::RadrootsTradeListingTotal,
order::{
TradeFulfillmentStatus, TradeOrder, TradeOrderChange, TradeOrderItem, TradeOrderStatus,
@@ -175,6 +180,12 @@ pub struct RadrootsTradeReadIndex {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsTradeProjectionError {
+ InvalidListingKind {
+ kind: u32,
+ },
+ InvalidListingContract {
+ error: TradeListingParseError,
+ },
MissingPrimaryBin(String),
MissingOrderId,
OrderIdMismatch,
@@ -189,11 +200,21 @@ pub enum RadrootsTradeProjectionError {
InvalidRevisionResponse,
NonOrderWorkflowMessage(TradeListingMessageType),
UnauthorizedActor,
+ #[cfg(feature = "serde_json")]
+ InvalidWorkflowEvent {
+ error: TradeListingEnvelopeParseError,
+ },
}
impl core::fmt::Display for RadrootsTradeProjectionError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
+ RadrootsTradeProjectionError::InvalidListingKind { kind } => {
+ write!(f, "invalid listing event kind: {kind}")
+ }
+ RadrootsTradeProjectionError::InvalidListingContract { error } => {
+ write!(f, "invalid listing contract event: {error}")
+ }
RadrootsTradeProjectionError::MissingPrimaryBin(bin_id) => {
write!(f, "missing primary bin: {bin_id}")
}
@@ -221,14 +242,36 @@ impl core::fmt::Display for RadrootsTradeProjectionError {
write!(f, "non-order workflow message: {message_type:?}")
}
RadrootsTradeProjectionError::UnauthorizedActor => write!(f, "unauthorized actor"),
+ #[cfg(feature = "serde_json")]
+ RadrootsTradeProjectionError::InvalidWorkflowEvent { error } => write!(f, "{error}"),
}
}
}
#[cfg(feature = "std")]
-impl std::error::Error for RadrootsTradeProjectionError {}
+impl std::error::Error for RadrootsTradeProjectionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ RadrootsTradeProjectionError::InvalidListingContract { error } => Some(error),
+ #[cfg(feature = "serde_json")]
+ RadrootsTradeProjectionError::InvalidWorkflowEvent { error } => Some(error),
+ _ => None,
+ }
+ }
+}
impl RadrootsTradeListingProjection {
+ pub fn from_listing_event(
+ event: &RadrootsNostrEvent,
+ ) -> Result<Self, RadrootsTradeProjectionError> {
+ if event.kind != KIND_LISTING {
+ return Err(RadrootsTradeProjectionError::InvalidListingKind { kind: event.kind });
+ }
+ let listing = listing_from_event_parts(&event.tags, &event.content)
+ .map_err(|error| RadrootsTradeProjectionError::InvalidListingContract { error })?;
+ Self::from_listing_contract(event.author.clone(), &listing)
+ }
+
pub fn from_listing_contract(
seller_pubkey: impl Into<String>,
listing: &RadrootsListing,
@@ -317,6 +360,17 @@ impl RadrootsTradeOrderWorkflowProjection {
}
impl RadrootsTradeOrderWorkflowMessage {
+ #[cfg(feature = "serde_json")]
+ pub fn from_event(event: &RadrootsNostrEvent) -> Result<Self, TradeListingEnvelopeParseError> {
+ let envelope = TradeListingEnvelope::<TradeListingMessagePayload>::from_event(event)?;
+ Ok(Self {
+ actor_pubkey: event.author.clone(),
+ listing_addr: envelope.listing_addr,
+ order_id: envelope.order_id,
+ payload: envelope.payload,
+ })
+ }
+
pub fn message_type(&self) -> TradeListingMessageType {
match &self.payload {
TradeListingMessagePayload::ListingValidateRequest(_) => {
@@ -392,6 +446,20 @@ impl RadrootsTradeReadIndex {
.expect("listing projection should exist after upsert"))
}
+ pub fn upsert_listing_event(
+ &mut self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<&RadrootsTradeListingProjection, RadrootsTradeProjectionError> {
+ let projection = RadrootsTradeListingProjection::from_listing_event(event)?;
+ let listing_addr = projection.listing_addr.clone();
+ self.listings.insert(listing_addr.clone(), projection);
+ self.refresh_listing_counts(&listing_addr);
+ Ok(self
+ .listings
+ .get(&listing_addr)
+ .expect("listing projection should exist after upsert"))
+ }
+
pub fn apply_workflow_message(
&mut self,
message: &RadrootsTradeOrderWorkflowMessage,
@@ -410,6 +478,16 @@ impl RadrootsTradeReadIndex {
.expect("order projection should exist after workflow apply"))
}
+ #[cfg(feature = "serde_json")]
+ pub fn apply_workflow_event(
+ &mut self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<&RadrootsTradeOrderWorkflowProjection, RadrootsTradeProjectionError> {
+ let message = RadrootsTradeOrderWorkflowMessage::from_event(event)
+ .map_err(|error| RadrootsTradeProjectionError::InvalidWorkflowEvent { error })?;
+ self.apply_workflow_message(&message)
+ }
+
fn apply_workflow_message_inner(
&mut self,
message: &RadrootsTradeOrderWorkflowMessage,
@@ -881,7 +959,11 @@ mod tests {
radroots_trade_order_status_can_transition, radroots_trade_order_status_is_terminal,
};
use crate::listing::{
- dvm::{TradeListingCancel, TradeListingMessagePayload, TradeOrderResponse},
+ codec::listing_tags_build,
+ dvm::{
+ TradeListingCancel, TradeListingEnvelopeParseError, TradeListingMessagePayload,
+ TradeOrderResponse, trade_listing_envelope_event_build,
+ },
order::{
TradeAnswer, TradeDiscountDecision, TradeDiscountOffer, TradeDiscountRequest,
TradeFulfillmentStatus, TradeFulfillmentUpdate, TradeOrder, TradeOrderChange,
@@ -897,6 +979,7 @@ mod tests {
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
RadrootsListingProduct, RadrootsListingStatus,
};
+ use radroots_events::{RadrootsNostrEvent, kinds::KIND_LISTING};
fn base_listing() -> RadrootsListing {
RadrootsListing {
@@ -1019,6 +1102,45 @@ mod tests {
}
}
+ fn listing_event(seller_pubkey: &str, listing: &RadrootsListing) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: "listing-event-id".into(),
+ author: seller_pubkey.into(),
+ created_at: 1_700_000_000,
+ kind: KIND_LISTING,
+ tags: listing_tags_build(listing).expect("listing tags"),
+ content: serde_json::to_string(listing).expect("listing json"),
+ sig: "sig".into(),
+ }
+ }
+
+ fn workflow_event(
+ actor_pubkey: &str,
+ recipient_pubkey: &str,
+ message_type: crate::listing::dvm::TradeListingMessageType,
+ listing_addr: &str,
+ order_id: Option<&str>,
+ payload: &TradeListingMessagePayload,
+ ) -> RadrootsNostrEvent {
+ let built = trade_listing_envelope_event_build(
+ recipient_pubkey,
+ message_type,
+ listing_addr.to_string(),
+ order_id.map(str::to_string),
+ payload,
+ )
+ .expect("trade workflow event");
+ RadrootsNostrEvent {
+ id: "workflow-event-id".into(),
+ author: actor_pubkey.into(),
+ created_at: 1_700_000_000,
+ kind: u32::from(built.kind),
+ tags: built.tags,
+ content: built.content,
+ sig: "sig".into(),
+ }
+ }
+
#[test]
fn listing_projection_builds_query_friendly_view() {
let mut index = RadrootsTradeReadIndex::new();
@@ -1043,6 +1165,46 @@ mod tests {
}
#[test]
+ fn listing_projection_can_ingest_canonical_nostr_event() {
+ let mut index = RadrootsTradeReadIndex::new();
+ let event = listing_event("seller-pubkey", &base_listing());
+
+ let projection = index
+ .upsert_listing_event(&event)
+ .expect("listing event projection");
+
+ assert_eq!(
+ projection.listing_addr,
+ "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg"
+ );
+ assert_eq!(projection.bins.len(), 2);
+ }
+
+ #[test]
+ fn workflow_projection_can_ingest_canonical_trade_event() {
+ let mut index = RadrootsTradeReadIndex::new();
+ index
+ .upsert_listing_event(&listing_event("seller-pubkey", &base_listing()))
+ .expect("listing projection");
+ let event = workflow_event(
+ "buyer-pubkey",
+ "seller-pubkey",
+ crate::listing::dvm::TradeListingMessageType::OrderRequest,
+ "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg",
+ Some("order-1"),
+ &TradeListingMessagePayload::OrderRequest(base_order()),
+ );
+
+ let order = index
+ .apply_workflow_event(&event)
+ .expect("workflow event projection");
+
+ assert_eq!(order.order_id, "order-1");
+ assert_eq!(order.status, TradeOrderStatus::Requested);
+ assert_eq!(order.last_actor_pubkey, "buyer-pubkey");
+ }
+
+ #[test]
fn workflow_projection_updates_order_and_listing_views() {
let mut index = RadrootsTradeReadIndex::new();
let listing = base_listing();
@@ -1339,6 +1501,30 @@ mod tests {
}
#[test]
+ fn workflow_projection_rejects_invalid_canonical_trade_event() {
+ let mut index = RadrootsTradeReadIndex::new();
+ let mut event = workflow_event(
+ "buyer-pubkey",
+ "seller-pubkey",
+ crate::listing::dvm::TradeListingMessageType::OrderRequest,
+ "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg",
+ Some("order-1"),
+ &TradeListingMessagePayload::OrderRequest(base_order()),
+ );
+ event.tags[1][1] = "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw".into();
+
+ let err = index
+ .apply_workflow_event(&event)
+ .expect_err("invalid workflow event should fail");
+ assert_eq!(
+ err,
+ RadrootsTradeProjectionError::InvalidWorkflowEvent {
+ error: TradeListingEnvelopeParseError::ListingAddrTagMismatch,
+ }
+ );
+ }
+
+ #[test]
fn workflow_helpers_cover_transition_and_terminal_tables() {
assert!(radroots_trade_order_status_can_transition(
&TradeOrderStatus::Requested,