lib

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

commit 49bd3b6c07dffae9f3978a71310485e5e5718a5c
parent 2b4bc355166eef308813b3e9a99fcaed3a59ea93
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 05:33:34 +0000

trade: bind active order economics

- add required economics snapshots to active order request payloads
- validate normalized item counts against economic item counts
- canonicalize request items and derived economics before signing
- update codec tests and conformance vectors for request economics

Diffstat:
Mcrates/events/src/trade.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/src/trade/decode.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/events_codec/src/trade/encode.rs | 3++-
Mcrates/trade/src/order.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mspec/conformance/vectors/trade/build_order_request_draft.v1.json | 43+++++++++++++++++++++++++++++++++++++++++--
Mspec/conformance/vectors/trade/parse_order_request.v1.json | 13+++++++++++--
6 files changed, 354 insertions(+), 15 deletions(-)

diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -1,7 +1,10 @@ #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use crate::{RadrootsNostrEventPtr, kinds::*}; use radroots_core::{ @@ -249,6 +252,12 @@ impl RadrootsTradeOrderEconomics { self.discounts.sort_by(|left, right| left.id.cmp(&right.id)); self.adjustments .sort_by(|left, right| left.id.cmp(&right.id)); + if let Ok(totals) = self.derived_totals() { + self.subtotal = totals.subtotal; + self.discount_total = totals.discount_total; + self.adjustment_total = totals.adjustment_total; + self.total = totals.total; + } } pub fn canonicalized(&self) -> Self { @@ -415,6 +424,7 @@ pub struct RadrootsTradeOrderRequested { pub buyer_pubkey: String, pub seller_pubkey: String, pub items: Vec<RadrootsTradeOrderItem>, + pub economics: RadrootsTradeOrderEconomics, } impl RadrootsTradeOrderRequested { @@ -423,7 +433,9 @@ impl RadrootsTradeOrderRequested { validate_required_field(&self.listing_addr, "listing_addr")?; validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; validate_required_field(&self.seller_pubkey, "seller_pubkey")?; - validate_order_items(&self.items) + validate_order_items(&self.items)?; + self.economics.validate()?; + validate_order_economics_binding(&self.items, &self.economics) } } @@ -1112,6 +1124,7 @@ pub enum RadrootsActiveTradePayloadError { InvalidEconomicCurrency { field: &'static str }, InvalidEconomicOrdering { field: &'static str }, InvalidEconomicTotal { field: &'static str }, + InvalidOrderEconomicsBinding { field: &'static str }, InvalidQuoteVersion, MissingInventoryCommitments, InvalidInventoryCommitmentCount { index: usize }, @@ -1167,6 +1180,9 @@ impl core::fmt::Display for RadrootsActiveTradePayloadError { Self::InvalidEconomicTotal { field } => { write!(f, "economics.{field} total is invalid") } + Self::InvalidOrderEconomicsBinding { field } => { + write!(f, "order {field} does not match economics") + } Self::InvalidQuoteVersion => { write!(f, "economics.quote_version must be greater than zero") } @@ -1261,6 +1277,67 @@ fn validate_economic_item( Ok(item.line_subtotal.clone()) } +fn validate_order_economics_binding( + items: &[RadrootsTradeOrderItem], + economics: &RadrootsTradeOrderEconomics, +) -> Result<(), RadrootsActiveTradePayloadError> { + let order_items = normalized_order_item_counts(items).ok_or( + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { + field: "items.bin_count", + }, + )?; + if order_items.len() != economics.items.len() { + return Err( + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { field: "items" }, + ); + } + for (item, economic_item) in order_items.iter().zip(economics.items.iter()) { + if item.bin_id != economic_item.bin_id { + return Err( + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { + field: "items.bin_id", + }, + ); + } + if item.bin_count != u64::from(economic_item.bin_count) { + return Err( + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { + field: "items.bin_count", + }, + ); + } + } + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +struct NormalizedOrderItemCount { + bin_id: String, + bin_count: u64, +} + +fn normalized_order_item_counts( + items: &[RadrootsTradeOrderItem], +) -> Option<Vec<NormalizedOrderItemCount>> { + let mut counts: Vec<NormalizedOrderItemCount> = Vec::new(); + for item in items { + let bin_id = item.bin_id.trim(); + if bin_id.is_empty() || item.bin_count == 0 { + return None; + } + if let Some(existing) = counts.iter_mut().find(|count| count.bin_id == bin_id) { + existing.bin_count = existing.bin_count.checked_add(u64::from(item.bin_count))?; + } else { + counts.push(NormalizedOrderItemCount { + bin_id: bin_id.to_string(), + bin_count: u64::from(item.bin_count), + }); + } + } + counts.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + Some(counts) +} + fn validate_economic_line( line: &RadrootsTradeOrderEconomicLine, expected_currency: RadrootsCoreCurrency, @@ -1521,6 +1598,7 @@ mod tests { bin_id: "bin-1".into(), bin_count: 2, }], + economics: sample_bound_order_economics(), } } @@ -1591,6 +1669,30 @@ mod tests { } } + fn sample_bound_order_economics() -> RadrootsTradeOrderEconomics { + RadrootsTradeOrderEconomics { + quote_id: "quote-bound-1".into(), + quote_version: 1, + pricing_basis: RadrootsTradePricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsTradeOrderEconomicItem { + bin_id: "bin-1".into(), + bin_count: 2, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("10"), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: usd("10"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("10"), + } + } + fn sample_inventory_commitment() -> RadrootsTradeInventoryCommitment { RadrootsTradeInventoryCommitment { bin_id: "bin-1".into(), @@ -1867,6 +1969,27 @@ mod tests { missing_bin_id.validate().unwrap_err(), RadrootsActiveTradePayloadError::EmptyField("bin_id") ); + + let mut mismatched_economic_item = sample_active_order_request(); + mismatched_economic_item.economics.items[0].bin_id = "bin-other".into(); + assert_eq!( + mismatched_economic_item.validate().unwrap_err(), + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { + field: "items.bin_id" + } + ); + + let mut mismatched_economic_count = sample_active_order_request(); + mismatched_economic_count.economics.items[0].bin_count = 3; + mismatched_economic_count.economics.items[0].line_subtotal = usd("15"); + mismatched_economic_count.economics.subtotal = usd("15"); + mismatched_economic_count.economics.total = usd("15"); + assert_eq!( + mismatched_economic_count.validate().unwrap_err(), + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { + field: "items.bin_count" + } + ); } #[test] @@ -1897,6 +2020,8 @@ mod tests { let mut economics = sample_active_order_economics(); economics.items.reverse(); economics.adjustments.reverse(); + economics.subtotal = usd("19"); + economics.total = usd("17"); assert_eq!( economics.validate().unwrap_err(), RadrootsActiveTradePayloadError::InvalidEconomicOrdering { @@ -1907,6 +2032,8 @@ mod tests { let canonical = economics.canonicalized(); assert_eq!(canonical.items[0].bin_id, "bin-a"); assert_eq!(canonical.adjustments[0].id, "adjustment-a"); + assert_eq!(canonical.subtotal, usd("18")); + assert_eq!(canonical.total, usd("16")); assert_eq!(canonical.validate(), Ok(())); } diff --git a/crates/events_codec/src/trade/decode.rs b/crates/events_codec/src/trade/decode.rs @@ -652,6 +652,9 @@ mod tests { active_trade_order_request_event_build, trade_envelope_event_build, }; use crate::trade::tags::TAG_LISTING_EVENT; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, + }; use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, kinds::{ @@ -662,11 +665,14 @@ mod tests { tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, trade::{ RadrootsActiveTradeEnvelope, RadrootsActiveTradeFulfillmentState, - RadrootsActiveTradeMessageType, RadrootsTradeBuyerReceipt, RadrootsTradeEnvelope, - RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, - RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrder, - RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, - RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, + RadrootsTradeBuyerReceipt, RadrootsTradeEnvelope, RadrootsTradeFulfillmentUpdated, + RadrootsTradeInventoryCommitment, RadrootsTradeMessagePayload, + RadrootsTradeMessageType, RadrootsTradeOrder, RadrootsTradeOrderCancelled, + RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, + RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine, + RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradePricingBasis, }, }; @@ -694,6 +700,39 @@ mod tests { bin_id: "lb".into(), bin_count: 3, }], + economics: request_economics(), + } + } + + fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().unwrap() + } + + fn usd(raw: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) + } + + fn request_economics() -> RadrootsTradeOrderEconomics { + RadrootsTradeOrderEconomics { + quote_id: "quote-1".into(), + quote_version: 1, + pricing_basis: RadrootsTradePricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsTradeOrderEconomicItem { + bin_id: "lb".into(), + bin_count: 3, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("15"), + }], + discounts: Vec::<RadrootsTradeOrderEconomicLine>::new(), + adjustments: Vec::<RadrootsTradeOrderEconomicLine>::new(), + subtotal: usd("15"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("15"), } } @@ -818,6 +857,8 @@ mod tests { built.tags[2], vec![TAG_D.to_string(), "order-1".to_string()] ); + assert_eq!(envelope.payload.economics.quote_id, "quote-1"); + assert_eq!(envelope.payload.economics.total, usd("15")); assert!( built .tags @@ -988,6 +1029,37 @@ mod tests { } #[test] + fn active_order_request_parse_rejects_mismatched_economics() { + let mut payload = active_order_request(); + let built = active_trade_order_request_event_build(&listing_event_ptr(), &payload).unwrap(); + payload.economics.items[0].bin_id = "other-bin".into(); + let envelope = RadrootsActiveTradeEnvelope::new( + RadrootsActiveTradeMessageType::TradeOrderRequested, + payload.listing_addr.clone(), + payload.order_id.clone(), + payload, + ); + let event = RadrootsNostrEvent { + id: "event-id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: serde_json::to_string(&envelope).unwrap(), + sig: "sig".into(), + }; + let err = active_trade_order_request_from_event(&event).unwrap_err(); + assert_eq!( + err, + RadrootsActiveTradeEnvelopeParseError::InvalidPayload( + RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { + field: "items.bin_id" + } + ) + ); + } + + #[test] fn active_order_decision_parse_roundtrips_and_validates_chain_tags() { let payload = active_order_decision(); let built = diff --git a/crates/events_codec/src/trade/encode.rs b/crates/events_codec/src/trade/encode.rs @@ -78,7 +78,8 @@ fn map_active_payload_error(error: RadrootsActiveTradePayloadError) -> EventEnco | RadrootsActiveTradePayloadError::InvalidEconomicLineEffect { field, .. } | RadrootsActiveTradePayloadError::InvalidEconomicCurrency { field } | RadrootsActiveTradePayloadError::InvalidEconomicOrdering { field } - | RadrootsActiveTradePayloadError::InvalidEconomicTotal { field } => { + | RadrootsActiveTradePayloadError::InvalidEconomicTotal { field } + | RadrootsActiveTradePayloadError::InvalidOrderEconomicsBinding { field } => { EventEncodeError::InvalidField(field) } RadrootsActiveTradePayloadError::InvalidQuoteVersion => { diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -655,6 +655,7 @@ pub fn canonicalize_active_order_request_for_signer( } canonicalize_items(&mut request.items)?; + request.economics.canonicalize(); request.order_id = order_id; request.listing_addr = listing_addr.as_str(); request.buyer_pubkey = buyer_pubkey; @@ -2061,17 +2062,34 @@ fn parse_public_listing_addr( } fn canonicalize_items( - items: &mut [RadrootsTradeOrderItem], + items: &mut Vec<RadrootsTradeOrderItem>, ) -> Result<(), RadrootsTradeOrderCanonicalizationError> { if items.is_empty() { return Err(RadrootsTradeOrderCanonicalizationError::MissingItems); } + let mut canonical_items: Vec<RadrootsTradeOrderItem> = Vec::new(); for (index, item) in items.iter_mut().enumerate() { - item.bin_id = normalized_required_string(item.bin_id.clone(), "bin_id")?; + item.bin_id = normalized_required_string(core::mem::take(&mut item.bin_id), "bin_id")?; if item.bin_count == 0 { return Err(RadrootsTradeOrderCanonicalizationError::InvalidBinCount { index }); } + if let Some(existing) = canonical_items + .iter_mut() + .find(|canonical| canonical.bin_id.as_str() == item.bin_id.as_str()) + { + existing.bin_count = existing + .bin_count + .checked_add(item.bin_count) + .ok_or(RadrootsTradeOrderCanonicalizationError::InvalidBinCount { index })?; + } else { + canonical_items.push(RadrootsTradeOrderItem { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }); + } } + canonical_items.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + *items = canonical_items; Ok(()) } @@ -2175,12 +2193,17 @@ fn normalized_required_string( #[cfg(test)] mod tests { + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, + }; use radroots_events::kinds::KIND_LISTING; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, - RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, + RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, + RadrootsTradeOrderRequested, RadrootsTradePricingBasis, }; use super::{ @@ -2222,6 +2245,43 @@ mod tests { bin_id: " bin-1 ".to_string(), bin_count: 2, }], + economics: request_economics("bin-1", 2, "10"), + } + } + + fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().unwrap() + } + + fn usd(raw: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) + } + + fn request_economics( + bin_id: &str, + bin_count: u32, + subtotal: &str, + ) -> RadrootsTradeOrderEconomics { + RadrootsTradeOrderEconomics { + quote_id: "quote-1".to_string(), + quote_version: 1, + pricing_basis: RadrootsTradePricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsTradeOrderEconomicItem { + bin_id: bin_id.to_string(), + bin_count, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd(subtotal), + }], + discounts: Vec::<RadrootsTradeOrderEconomicLine>::new(), + adjustments: Vec::<RadrootsTradeOrderEconomicLine>::new(), + subtotal: usd(subtotal), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd(subtotal), } } @@ -2254,6 +2314,7 @@ mod tests { bin_id: "bin-1".to_string(), bin_count: 2, }], + economics: request_economics("bin-1", 2, "10"), } } @@ -2277,6 +2338,9 @@ mod tests { let mut request = request_record_with_event_id(event_id); request.payload.order_id = order_id.to_string(); request.payload.items[0].bin_count = bin_count; + let subtotal = + (RadrootsCoreDecimal::from(5u32) * RadrootsCoreDecimal::from(bin_count)).to_string(); + request.payload.economics = request_economics("bin-1", bin_count, &subtotal); request } @@ -2435,6 +2499,33 @@ mod tests { } #[test] + fn canonicalize_active_order_request_merges_duplicate_items() { + let mut request = active_request("", ""); + request.economics.total = usd("12"); + request.items = vec![ + RadrootsTradeOrderItem { + bin_id: " bin-1 ".to_string(), + bin_count: 1, + }, + RadrootsTradeOrderItem { + bin_id: "bin-1".to_string(), + bin_count: 1, + }, + ]; + + let request = canonicalize_active_order_request_for_signer(request, BUYER).unwrap(); + + assert_eq!( + request.items, + vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_string(), + bin_count: 2, + }] + ); + assert_eq!(request.economics.total, usd("10")); + } + + #[test] fn canonicalize_active_order_request_rejects_wrong_buyer_signer() { let error = canonicalize_active_order_request_for_signer(active_request(SELLER, ""), BUYER) .unwrap_err(); diff --git a/spec/conformance/vectors/trade/build_order_request_draft.v1.json b/spec/conformance/vectors/trade/build_order_request_draft.v1.json @@ -20,14 +20,53 @@ "bin_id": "bin-1", "bin_count": 2 } - ] + ], + "economics": { + "quote_id": "quote-1", + "quote_version": 1, + "pricing_basis": "listing_event", + "currency": "USD", + "items": [ + { + "bin_id": "bin-1", + "bin_count": 2, + "quantity_amount": "1", + "quantity_unit": "each", + "unit_price_amount": "5", + "unit_price_currency": "USD", + "line_subtotal": { + "amount": "10", + "currency": "USD" + } + } + ], + "discounts": [], + "adjustments": [], + "subtotal": { + "amount": "10", + "currency": "USD" + }, + "discount_total": { + "amount": "0", + "currency": "USD" + }, + "adjustment_total": { + "amount": "0", + "currency": "USD" + }, + "total": { + "amount": "10", + "currency": "USD" + } + } } }, "expected": { "wire_parts": { "kind": 3422, "content_type": "TradeOrderRequested", - "tags_shape": "active_trade_order_request_tags" + "tags_shape": "active_trade_order_request_tags", + "payload_shape": "active_trade_order_request_with_economics" } } } diff --git a/spec/conformance/vectors/trade/parse_order_request.v1.json b/spec/conformance/vectors/trade/parse_order_request.v1.json @@ -10,12 +10,21 @@ "kind": 3422, "author": "buyer_pubkey", "content_type": "TradeOrderRequested", - "tags_shape": "active_trade_order_request_tags" + "tags_shape": "active_trade_order_request_tags", + "payload_shape": "active_trade_order_request_with_economics" } }, "expected": { "envelope_shape": "active_trade_order_request", - "payload_type": "RadrootsTradeOrderRequested" + "payload_type": "RadrootsTradeOrderRequested", + "required_payload_fields": [ + "order_id", + "listing_addr", + "buyer_pubkey", + "seller_pubkey", + "items", + "economics" + ] } } ]