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:
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"
+ ]
}
}
]