lib

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

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:
Mcrates/trade/src/listing/dvm.rs | 324++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/listing/projection.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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,