commit d525ffc7ad6d54e2aa3cf7853fd9acdf44f059e6
parent 3acd0fb0e61851ab20f85dedd7fa8aea6b98b495
Author: triesap <tyson@radroots.org>
Date: Sun, 21 Jun 2026 18:56:46 +0000
trade: expand reducer coverage
- add deterministic order reducer, inventory accounting, and receipt validation edge coverage
- exercise listing address parsing, listing validation, and codec error paths used by trade coverage gates
- simplify unreachable typed-id and inline proof validation branches
- validate with cargo test/check, diff check, and radroots_trade coverage gate
Diffstat:
5 files changed, 2617 insertions(+), 34 deletions(-)
diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs
@@ -95,10 +95,7 @@ pub fn listing_from_event_parts(
#[cfg(feature = "serde_json")]
{
if let Ok(mut listing) = serde_json::from_str::<RadrootsListing>(content) {
- if listing.d_tag.trim().is_empty() {
- listing.d_tag = RadrootsDTag::parse(&d_tag)
- .map_err(|_| ListingParseError::InvalidTag(TAG_D.to_string()))?;
- } else if listing.d_tag != d_tag {
+ if listing.d_tag != d_tag {
return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
}
if listing.farm.pubkey.trim().is_empty() || listing.farm.d_tag.trim().is_empty() {
@@ -770,6 +767,19 @@ mod tests {
.unwrap_err();
assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
+
+ let mut missing_value = base_trade_tags();
+ missing_value.push(vec![TAG_PUBLISHED_AT.into()]);
+ let err = listing_from_tags(
+ &missing_value,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
}
#[test]
@@ -1922,6 +1932,19 @@ mod tests {
)
.unwrap_err();
assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
+
+ let mut tags = base_trade_tags();
+ tags[4][1] = "bad id".into();
+ let err = listing_from_tags(
+ &tags,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
}
#[test]
@@ -2295,6 +2318,28 @@ mod tests {
TAG_RADROOTS_PRICE.to_string()
);
+ let draft_invalid_bin = BinDraft {
+ bin_id: "bad id".into(),
+ order_index: 0,
+ quantity: Some(RadrootsCoreQuantity::new(
+ "1".parse().unwrap(),
+ RadrootsCoreUnit::MassG,
+ )),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
+ )),
+ display_price: None,
+ display_price_unit: None,
+ };
+ assert_eq!(
+ parse_error_tag(build_bins(vec![draft_invalid_bin]).unwrap_err()),
+ TAG_RADROOTS_BIN.to_string()
+ );
+
let tags = vec![
vec!["key".into(), "coffee".into()],
vec!["title".into(), "Coffee".into()],
diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs
@@ -126,17 +126,54 @@ pub fn parse_listing_event(
#[cfg(test)]
mod tests {
use super::{
- RadrootsListingAddressError, RadrootsPublicListingAddressError, parse_listing_address,
- parse_listing_event, parse_public_listing_address,
+ RadrootsListingAddressError, RadrootsListingAddressParts, RadrootsPublicListingAddress,
+ RadrootsPublicListingAddressError, parse_listing_address, parse_listing_event,
+ parse_public_listing_address,
};
use radroots_events::{
RadrootsNostrEvent,
+ ids::RadrootsListingAddress,
kinds::{KIND_LISTING, KIND_LISTING_DRAFT, KIND_PROFILE},
order::RadrootsListingParseError,
};
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ fn listing_event() -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: "event-1".into(),
+ author: SELLER.into(),
+ created_at: 1,
+ kind: KIND_LISTING,
+ tags: vec![
+ vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
+ vec!["p".into(), SELLER.into()],
+ vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
+ vec!["key".into(), "coffee".into()],
+ vec!["title".into(), "Coffee".into()],
+ vec!["category".into(), "coffee".into()],
+ vec!["summary".into(), "Single origin".into()],
+ vec!["radroots:primary_bin".into(), "bin-1".into()],
+ vec![
+ "radroots:bin".into(),
+ "bin-1".into(),
+ "1000".into(),
+ "g".into(),
+ ],
+ vec![
+ "radroots:price".into(),
+ "bin-1".into(),
+ "20".into(),
+ "USD".into(),
+ "1".into(),
+ "g".into(),
+ ],
+ ],
+ content: String::new(),
+ sig: String::new(),
+ }
+ }
+
#[test]
fn parse_listing_event_rejects_non_listing_kind() {
let event = RadrootsNostrEvent {
@@ -156,6 +193,33 @@ mod tests {
}
#[test]
+ fn parse_listing_event_accepts_listing_kind() {
+ let listing = parse_listing_event(&listing_event()).expect("listing");
+
+ assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ assert_eq!(listing.farm.pubkey, SELLER);
+ assert_eq!(listing.primary_bin_id.as_str(), "bin-1");
+ }
+
+ #[test]
+ fn listing_address_associated_parsers_delegate_to_public_parsers() {
+ let raw = format!("{KIND_LISTING}:{SELLER}:listing-1");
+
+ let listing = RadrootsListingAddressParts::parse(raw.clone()).expect("listing address");
+ let public = RadrootsPublicListingAddress::parse(&raw).expect("public address");
+ let typed =
+ parse_public_listing_address(RadrootsListingAddress::parse(&raw).expect("typed addr"))
+ .expect("typed public address");
+
+ assert_eq!(listing.address.as_str(), raw);
+ assert_eq!(public.address.as_str(), raw);
+ assert_eq!(typed.address.as_str(), raw);
+ assert_eq!(listing.seller_pubkey.as_str(), SELLER);
+ assert_eq!(public.seller_pubkey.as_str(), SELLER);
+ assert_eq!(typed.seller_pubkey.as_str(), SELLER);
+ }
+
+ #[test]
fn parse_public_listing_address_accepts_public_listing_kind() {
let raw = format!("{KIND_LISTING}:{SELLER}:listing-1");
let parsed = parse_public_listing_address(&raw).expect("public listing address");
@@ -190,6 +254,22 @@ mod tests {
}
#[test]
+ fn parse_public_listing_address_maps_invalid_listing_addresses() {
+ assert!(matches!(
+ parse_public_listing_address("not-an-address"),
+ Err(RadrootsPublicListingAddressError::InvalidAddress(_))
+ ));
+
+ let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
+ assert!(matches!(
+ parse_public_listing_address(&raw),
+ Err(RadrootsPublicListingAddressError::InvalidListingKind {
+ actual: KIND_PROFILE
+ })
+ ));
+ }
+
+ #[test]
fn parse_listing_address_rejects_non_listing_kind() {
let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -90,9 +90,6 @@ pub fn validate_listing_event(
return Err(TradeListingValidationError::MissingBins);
}
let primary_bin_id = listing.primary_bin_id.trim().to_string();
- if primary_bin_id.is_empty() {
- return Err(TradeListingValidationError::MissingPrimaryBin);
- }
let primary_bin = listing
.bins
.iter()
@@ -361,6 +358,24 @@ mod tests {
}
#[test]
+ fn validate_listing_rejects_invalid_listing_address_parts() {
+ let mut listing = base_listing();
+ listing.farm.pubkey = "not-a-pubkey".into();
+ let mut event = base_event(&listing);
+ event.author = "not-a-pubkey".into();
+ let err = validate_listing_event(&event).unwrap_err();
+
+ assert_eq!(
+ err,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::codec::ListingParseError::InvalidTag(
+ "listing_addr".to_string()
+ )
+ }
+ );
+ }
+
+ #[test]
fn validate_listing_rejects_missing_inventory() {
let mut listing = base_listing();
listing.inventory_available = None;
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -15,11 +15,6 @@ use radroots_events::ids::{
RadrootsOrderId, RadrootsPublicKey,
};
#[cfg(feature = "serde_json")]
-use radroots_events::kinds::{
- KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION,
- KIND_ORDER_REVISION_PROPOSAL,
-};
-#[cfg(feature = "serde_json")]
use radroots_events::order::RadrootsOrderEventType;
use radroots_events::order::{
RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
@@ -180,8 +175,8 @@ pub fn order_event_record_from_event(
let author_pubkey = RadrootsPublicKey::parse(&event.author)
.map_err(RadrootsOrderEventDecodeError::InvalidAuthor)?;
- match event.kind {
- KIND_ORDER_REQUEST => {
+ match message_type {
+ RadrootsOrderEventType::OrderRequested => {
let envelope = order_request_from_event(event)?;
Ok(RadrootsOrderEventRecord::Request(
RadrootsOrderRequestRecord {
@@ -191,7 +186,7 @@ pub fn order_event_record_from_event(
},
))
}
- KIND_ORDER_DECISION => {
+ RadrootsOrderEventType::OrderDecision => {
let envelope = order_decision_from_event(event)?;
Ok(RadrootsOrderEventRecord::Decision(
RadrootsOrderDecisionRecord {
@@ -204,7 +199,7 @@ pub fn order_event_record_from_event(
},
))
}
- KIND_ORDER_REVISION_PROPOSAL => {
+ RadrootsOrderEventType::OrderRevisionProposed => {
let envelope = order_revision_proposal_from_event(event)?;
Ok(RadrootsOrderEventRecord::RevisionProposal(
RadrootsOrderRevisionProposalRecord {
@@ -217,7 +212,7 @@ pub fn order_event_record_from_event(
},
))
}
- KIND_ORDER_REVISION_DECISION => {
+ RadrootsOrderEventType::OrderRevisionDecision => {
let envelope = order_revision_decision_from_event(event)?;
Ok(RadrootsOrderEventRecord::RevisionDecision(
RadrootsOrderRevisionDecisionRecord {
@@ -230,7 +225,7 @@ pub fn order_event_record_from_event(
},
))
}
- KIND_ORDER_CANCELLATION => {
+ RadrootsOrderEventType::OrderCancelled => {
let envelope = order_cancellation_from_event(event)?;
Ok(RadrootsOrderEventRecord::Cancellation(
RadrootsOrderCancellationRecord {
@@ -243,7 +238,6 @@ pub fn order_event_record_from_event(
},
))
}
- _ => Err(RadrootsOrderEventDecodeError::UnsupportedKind { kind: event.kind }),
}
}
@@ -2167,24 +2161,28 @@ fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 {
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::{
- RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryBinAvailability,
- RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderEventRecord,
+ RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryAccountingIssue,
+ RadrootsListingInventoryBinAvailability, RadrootsOrderCancellationRecord,
+ RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue,
RadrootsOrderReductionInputs, RadrootsOrderRequestRecord,
RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord,
RadrootsOrderStatus, reduce_listing_inventory_accounting, reduce_order_event_records,
reduce_order_events,
};
+ use core::mem::discriminant;
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
};
use radroots_events::{
+ RadrootsNostrEvent, RadrootsNostrEventPtr,
ids::{
RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId,
RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey,
},
- kinds::KIND_LISTING,
+ kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
order::{
RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment,
@@ -2193,9 +2191,18 @@ mod tests {
RadrootsOrderRevisionProposal,
},
};
+ #[cfg(feature = "serde_json")]
+ use radroots_events_codec::{
+ order::{
+ order_cancellation_event_build, order_decision_event_build, order_request_event_build,
+ order_revision_decision_event_build, order_revision_proposal_event_build,
+ },
+ wire::WireEventParts,
+ };
const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const OTHER: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
fn event_id(raw: u8) -> RadrootsEventId {
RadrootsEventId::parse(format!("{raw:064x}")).expect("event id")
@@ -2226,6 +2233,39 @@ mod tests {
.expect("listing address")
}
+ fn draft_listing_addr() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!(
+ "{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("draft listing address")
+ }
+
+ fn other_seller_listing_addr() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!("{KIND_LISTING}:{OTHER}:AAAAAAAAAAAAAAAAAAAAAg"))
+ .expect("other seller listing address")
+ }
+
+ #[cfg(feature = "serde_json")]
+ fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: event_id(80).into_string(),
+ relays: Some("wss://relay.example.test".into()),
+ }
+ }
+
+ #[cfg(feature = "serde_json")]
+ fn event_from_parts(raw_id: u8, author: &str, parts: WireEventParts) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: event_id(raw_id).into_string(),
+ author: author.into(),
+ created_at: 1,
+ kind: parts.kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: "sig".into(),
+ }
+ }
+
fn economics(bin_count: u32) -> RadrootsOrderEconomics {
let currency = RadrootsCoreCurrency::USD;
let amount = RadrootsCoreDecimal::from(1200u32);
@@ -2382,6 +2422,108 @@ mod tests {
}
}
+ fn assert_order_issue_kind(issues: &[RadrootsOrderIssue], expected: RadrootsOrderIssue) {
+ let expected_kind = discriminant(&expected);
+ assert!(
+ issues
+ .iter()
+ .any(|issue| discriminant(issue) == expected_kind),
+ "missing issue kind {expected:?} in {issues:?}"
+ );
+ }
+
+ fn assert_inventory_issue_kind(
+ issues: &[RadrootsListingInventoryAccountingIssue],
+ expected: RadrootsListingInventoryAccountingIssue,
+ ) {
+ let expected_kind = discriminant(&expected);
+ assert!(
+ issues
+ .iter()
+ .any(|issue| discriminant(issue) == expected_kind),
+ "missing inventory issue kind {expected:?} in {issues:?}"
+ );
+ }
+
+ fn assert_request_issue(
+ mutate: impl FnOnce(&mut RadrootsOrderRequestRecord),
+ expected: RadrootsOrderIssue,
+ ) {
+ let mut request = request_record();
+ mutate(&mut request);
+ let mut issues = Vec::new();
+ assert!(!super::validate_order_request_record(
+ &order_id("order-1"),
+ &request,
+ &mut issues
+ ));
+ assert_order_issue_kind(&issues, expected);
+ }
+
+ fn assert_decision_issue(
+ mutate: impl FnOnce(&mut RadrootsOrderDecisionRecord),
+ expected: RadrootsOrderIssue,
+ ) {
+ let request = request_record();
+ let mut decision = accepted_decision();
+ mutate(&mut decision);
+ let mut issues = Vec::new();
+ assert!(!super::validate_order_decision_record(
+ &request,
+ &decision,
+ &mut issues
+ ));
+ assert_order_issue_kind(&issues, expected);
+ }
+
+ fn assert_revision_proposal_issue(
+ mutate: impl FnOnce(&mut RadrootsOrderRevisionProposalRecord),
+ expected: RadrootsOrderIssue,
+ ) {
+ let request = request_record();
+ let mut proposal = revision_proposal();
+ mutate(&mut proposal);
+ let mut issues = Vec::new();
+ assert!(!super::validate_order_revision_proposal_record(
+ &request,
+ &proposal,
+ &mut issues
+ ));
+ assert_order_issue_kind(&issues, expected);
+ }
+
+ fn assert_revision_decision_issue(
+ mutate: impl FnOnce(&mut RadrootsOrderRevisionDecisionRecord),
+ expected: RadrootsOrderIssue,
+ ) {
+ let request = request_record();
+ let mut decision = accepted_revision_decision();
+ mutate(&mut decision);
+ let mut issues = Vec::new();
+ assert!(!super::validate_order_revision_decision_record(
+ &request,
+ &decision,
+ &mut issues
+ ));
+ assert_order_issue_kind(&issues, expected);
+ }
+
+ fn assert_cancellation_issue(
+ mutate: impl FnOnce(&mut RadrootsOrderCancellationRecord),
+ expected: RadrootsOrderIssue,
+ ) {
+ let request = request_record();
+ let mut cancellation = cancellation(event_id(1));
+ mutate(&mut cancellation);
+ let mut issues = Vec::new();
+ assert!(!super::validate_order_cancellation_record(
+ &request,
+ &cancellation,
+ &mut issues
+ ));
+ assert_order_issue_kind(&issues, expected);
+ }
+
fn reduce(
decisions: Vec<RadrootsOrderDecisionRecord>,
revision_proposals: Vec<RadrootsOrderRevisionProposalRecord>,
@@ -2401,6 +2543,1729 @@ mod tests {
}
#[test]
+ fn order_event_record_accessors_cover_all_variants() {
+ let records = vec![
+ RadrootsOrderEventRecord::Request(request_record()),
+ RadrootsOrderEventRecord::Decision(accepted_decision()),
+ RadrootsOrderEventRecord::RevisionProposal(revision_proposal()),
+ RadrootsOrderEventRecord::RevisionDecision(accepted_revision_decision()),
+ RadrootsOrderEventRecord::Cancellation(cancellation(event_id(1))),
+ ];
+
+ let event_ids = records
+ .iter()
+ .map(RadrootsOrderEventRecord::event_id)
+ .cloned()
+ .collect::<Vec<_>>();
+ let order_ids = records
+ .iter()
+ .map(RadrootsOrderEventRecord::order_id)
+ .cloned()
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ event_ids,
+ vec![
+ event_id(1),
+ event_id(2),
+ event_id(3),
+ event_id(4),
+ event_id(5)
+ ]
+ );
+ assert_eq!(order_ids, vec![order_id("order-1"); 5]);
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn order_event_records_decode_wire_events_and_decode_errors() {
+ let request = request_record();
+ let request_parts =
+ order_request_event_build(&listing_event_ptr(), &request.payload).unwrap();
+ let request_record =
+ super::order_event_record_from_event(&event_from_parts(11, BUYER, request_parts))
+ .unwrap();
+ assert!(matches!(
+ request_record,
+ RadrootsOrderEventRecord::Request(record)
+ if record.event_id == event_id(11)
+ && record.author_pubkey == public_key(BUYER)
+ && record.payload.order_id == order_id("order-1")
+ ));
+
+ let decision = accepted_decision();
+ let decision_parts = order_decision_event_build(
+ &decision.root_event_id,
+ &decision.prev_event_id,
+ &decision.payload,
+ )
+ .unwrap();
+ let decision_record =
+ super::order_event_record_from_event(&event_from_parts(12, SELLER, decision_parts))
+ .unwrap();
+ assert!(matches!(
+ decision_record,
+ RadrootsOrderEventRecord::Decision(record)
+ if record.event_id == event_id(12)
+ && record.counterparty_pubkey == public_key(BUYER)
+ && record.root_event_id == event_id(1)
+ && record.prev_event_id == event_id(1)
+ ));
+
+ let proposal = revision_proposal();
+ let proposal_parts = order_revision_proposal_event_build(
+ &proposal.root_event_id,
+ &proposal.prev_event_id,
+ &proposal.payload,
+ )
+ .unwrap();
+ let proposal_record =
+ super::order_event_record_from_event(&event_from_parts(13, SELLER, proposal_parts))
+ .unwrap();
+ assert!(matches!(
+ proposal_record,
+ RadrootsOrderEventRecord::RevisionProposal(record)
+ if record.event_id == event_id(13)
+ && record.counterparty_pubkey == public_key(BUYER)
+ && record.payload.revision_id == revision_id("revision-1")
+ ));
+
+ let revision_decision = accepted_revision_decision();
+ let revision_decision_parts = order_revision_decision_event_build(
+ &revision_decision.root_event_id,
+ &revision_decision.prev_event_id,
+ &revision_decision.payload,
+ )
+ .unwrap();
+ let revision_decision_record = super::order_event_record_from_event(&event_from_parts(
+ 14,
+ BUYER,
+ revision_decision_parts,
+ ))
+ .unwrap();
+ assert!(matches!(
+ revision_decision_record,
+ RadrootsOrderEventRecord::RevisionDecision(record)
+ if record.event_id == event_id(14)
+ && record.counterparty_pubkey == public_key(SELLER)
+ && record.payload.revision_id == revision_id("revision-1")
+ ));
+
+ let cancellation = cancellation(event_id(1));
+ let cancellation_parts = order_cancellation_event_build(
+ &cancellation.root_event_id,
+ &cancellation.prev_event_id,
+ &cancellation.payload,
+ )
+ .unwrap();
+ let cancellation_record =
+ super::order_event_record_from_event(&event_from_parts(15, BUYER, cancellation_parts))
+ .unwrap();
+ assert!(matches!(
+ cancellation_record,
+ RadrootsOrderEventRecord::Cancellation(record)
+ if record.event_id == event_id(15)
+ && record.counterparty_pubkey == public_key(SELLER)
+ && record.payload.reason == "changed plans"
+ ));
+
+ let unsupported = RadrootsNostrEvent {
+ id: event_id(16).into_string(),
+ author: BUYER.into(),
+ created_at: 1,
+ kind: 1,
+ tags: Vec::new(),
+ content: "{}".into(),
+ sig: "sig".into(),
+ };
+ assert!(matches!(
+ super::order_event_record_from_event(&unsupported),
+ Err(super::RadrootsOrderEventDecodeError::UnsupportedKind { kind: 1 })
+ ));
+
+ let request_parts =
+ order_request_event_build(&listing_event_ptr(), &request.payload).unwrap();
+ let mut invalid_id_event = event_from_parts(17, BUYER, request_parts);
+ invalid_id_event.id = "not-an-event-id".into();
+ assert!(matches!(
+ super::order_event_record_from_event(&invalid_id_event),
+ Err(super::RadrootsOrderEventDecodeError::InvalidEventId(_))
+ ));
+
+ let request_parts =
+ order_request_event_build(&listing_event_ptr(), &request.payload).unwrap();
+ let mut invalid_author_event = event_from_parts(18, BUYER, request_parts);
+ invalid_author_event.author = "not-a-pubkey".into();
+ assert!(matches!(
+ super::order_event_record_from_event(&invalid_author_event),
+ Err(super::RadrootsOrderEventDecodeError::InvalidAuthor(_))
+ ));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn order_event_context_requirements_report_missing_chain_ids() {
+ let context = radroots_events_codec::order::RadrootsOrderEventContext {
+ counterparty_pubkey: public_key(BUYER),
+ listing_event: None,
+ root_event_id: None,
+ prev_event_id: None,
+ };
+
+ assert!(matches!(
+ super::require_context_root_event_id(&context),
+ Err(super::RadrootsOrderEventDecodeError::MissingRootEventId)
+ ));
+ assert!(matches!(
+ super::require_context_prev_event_id(&context),
+ Err(super::RadrootsOrderEventDecodeError::MissingPreviousEventId)
+ ));
+ }
+
+ #[test]
+ fn reducer_groups_all_record_variants_and_skips_duplicate_event_ids() {
+ let mut duplicate_decision = declined_decision();
+ duplicate_decision.event_id = event_id(2);
+ let projection = reduce_order_event_records(
+ &order_id("order-1"),
+ vec![
+ RadrootsOrderEventRecord::Cancellation(cancellation(event_id(1))),
+ RadrootsOrderEventRecord::RevisionDecision(accepted_revision_decision()),
+ RadrootsOrderEventRecord::RevisionProposal(revision_proposal()),
+ RadrootsOrderEventRecord::Decision(accepted_decision()),
+ RadrootsOrderEventRecord::Decision(duplicate_decision),
+ RadrootsOrderEventRecord::Request(request_record()),
+ ],
+ );
+
+ assert_eq!(projection.status, RadrootsOrderStatus::Invalid);
+ assert_order_issue_kind(
+ &projection.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+ assert_eq!(
+ super::projection_issue_event_ids(&projection.issues),
+ vec![event_id(2), event_id(4), event_id(5)]
+ );
+ }
+
+ #[test]
+ fn canonicalize_order_request_reports_signer_listing_and_item_errors() {
+ let canonical =
+ super::canonicalize_order_request_for_signer(request_record().payload, BUYER).unwrap();
+ assert_eq!(canonical.buyer_pubkey, public_key(BUYER));
+ assert_eq!(canonical.seller_pubkey, public_key(SELLER));
+
+ let mut unsorted_items = request_record().payload;
+ unsorted_items.items.push(RadrootsOrderItem {
+ bin_id: bin_id("bin-0"),
+ bin_count: 1,
+ });
+ let canonical =
+ super::canonicalize_order_request_for_signer(unsorted_items, BUYER).unwrap();
+ assert_eq!(canonical.items[0].bin_id, bin_id("bin-0"));
+ assert_eq!(canonical.items[1].bin_id, bin_id("bin-1"));
+
+ assert!(matches!(
+ super::parse_public_listing_addr("not-an-address"),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidListingAddress(_))
+ ));
+ assert!(matches!(
+ super::parse_public_listing_addr(format!(
+ "{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ )),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind)
+ ));
+
+ assert!(matches!(
+ super::canonicalize_order_request_for_signer(request_record().payload, SELLER),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidBuyerSigner)
+ ));
+
+ let mut seller_mismatch = request_record().payload;
+ seller_mismatch.seller_pubkey = public_key(OTHER);
+ assert!(matches!(
+ super::canonicalize_order_request_for_signer(seller_mismatch, BUYER),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
+ ));
+
+ let mut invalid_kind = request_record().payload;
+ invalid_kind.listing_addr = draft_listing_addr();
+ assert!(matches!(
+ super::canonicalize_order_request_for_signer(invalid_kind, BUYER),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind)
+ ));
+
+ let mut missing_items = request_record().payload;
+ missing_items.items.clear();
+ assert!(matches!(
+ super::canonicalize_order_request_for_signer(missing_items, BUYER),
+ Err(super::RadrootsOrderCanonicalizationError::MissingItems)
+ ));
+
+ let mut zero_count = request_record().payload;
+ zero_count.items[0].bin_count = 0;
+ assert!(matches!(
+ super::canonicalize_order_request_for_signer(zero_count, BUYER),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidBinCount { index: 0 })
+ ));
+ }
+
+ #[test]
+ fn canonicalize_order_decision_reports_signer_and_decision_errors() {
+ let canonical =
+ super::canonicalize_order_decision_for_signer(accepted_decision().payload, SELLER)
+ .unwrap();
+ assert_eq!(canonical.seller_pubkey, public_key(SELLER));
+
+ let mut unsorted_commitments = accepted_decision().payload;
+ if let RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments,
+ } = &mut unsorted_commitments.decision
+ {
+ inventory_commitments.push(RadrootsOrderInventoryCommitment {
+ bin_id: bin_id("bin-0"),
+ bin_count: 1,
+ });
+ }
+ let canonical =
+ super::canonicalize_order_decision_for_signer(unsorted_commitments, SELLER).unwrap();
+ if let RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments,
+ } = canonical.decision
+ {
+ assert_eq!(inventory_commitments[0].bin_id, bin_id("bin-0"));
+ assert_eq!(inventory_commitments[1].bin_id, bin_id("bin-1"));
+ }
+
+ let mut listing_seller_mismatch = accepted_decision().payload;
+ listing_seller_mismatch.listing_addr = other_seller_listing_addr();
+ assert!(matches!(
+ super::canonicalize_order_decision_for_signer(listing_seller_mismatch, SELLER),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
+ ));
+
+ assert!(matches!(
+ super::canonicalize_order_decision_for_signer(accepted_decision().payload, BUYER),
+ Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
+ ));
+
+ let mut missing_commitments = accepted_decision().payload;
+ missing_commitments.decision = RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: Vec::new(),
+ };
+ assert!(matches!(
+ super::canonicalize_order_decision_for_signer(missing_commitments, SELLER),
+ Err(super::RadrootsOrderCanonicalizationError::MissingInventoryCommitments)
+ ));
+
+ let mut zero_commitment = accepted_decision().payload;
+ if let RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments,
+ } = &mut zero_commitment.decision
+ {
+ inventory_commitments[0].bin_count = 0;
+ }
+ assert!(matches!(
+ super::canonicalize_order_decision_for_signer(zero_commitment, SELLER),
+ Err(
+ super::RadrootsOrderCanonicalizationError::InvalidInventoryCommitmentCount {
+ index: 0
+ }
+ )
+ ));
+
+ let mut declined = declined_decision().payload;
+ declined.decision = RadrootsOrderDecisionOutcome::Declined {
+ reason: " already sold ".into(),
+ };
+ let declined = super::canonicalize_order_decision_for_signer(declined, SELLER).unwrap();
+ assert_eq!(
+ declined.decision,
+ RadrootsOrderDecisionOutcome::Declined {
+ reason: "already sold".into()
+ }
+ );
+
+ let mut blank_reason = declined_decision().payload;
+ blank_reason.decision = RadrootsOrderDecisionOutcome::Declined { reason: " ".into() };
+ assert!(matches!(
+ super::canonicalize_order_decision_for_signer(blank_reason, SELLER),
+ Err(super::RadrootsOrderCanonicalizationError::EmptyField(
+ "reason"
+ ))
+ ));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn order_economics_digest_is_stable_sha256_hex() {
+ let digest = super::radroots_order_economics_digest(&economics(2)).unwrap();
+ assert_eq!(
+ digest,
+ super::radroots_order_economics_digest(&economics(2)).unwrap()
+ );
+ assert!(digest.starts_with("sha256:"));
+ assert_eq!(digest.len(), "sha256:".len() + 64);
+ }
+
+ #[test]
+ fn order_helper_sorting_and_matching_paths_are_deterministic() {
+ let request_items = vec![
+ RadrootsOrderItem {
+ bin_id: bin_id("bin-2"),
+ bin_count: 1,
+ },
+ RadrootsOrderItem {
+ bin_id: bin_id("bin-1"),
+ bin_count: 2,
+ },
+ ];
+ let matching_commitments = vec![
+ RadrootsOrderInventoryCommitment {
+ bin_id: bin_id("bin-1"),
+ bin_count: 2,
+ },
+ RadrootsOrderInventoryCommitment {
+ bin_id: bin_id("bin-2"),
+ bin_count: 1,
+ },
+ ];
+ assert!(super::inventory_commitments_match_request(
+ &request_items,
+ &matching_commitments
+ ));
+ assert!(!super::inventory_commitments_match_request(
+ &request_items,
+ &matching_commitments[..1]
+ ));
+ let mut count_mismatch = matching_commitments.clone();
+ count_mismatch[0].bin_count = 1;
+ assert!(!super::inventory_commitments_match_request(
+ &request_items,
+ &count_mismatch
+ ));
+ let mut bin_mismatch = matching_commitments.clone();
+ bin_mismatch[0].bin_id = bin_id("bin-3");
+ assert!(!super::inventory_commitments_match_request(
+ &request_items,
+ &bin_mismatch
+ ));
+
+ let mut order_issues = vec![
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: vec![event_id(9), event_id(3)],
+ },
+ RadrootsOrderIssue::CancellationWithoutCancellableOrder {
+ event_id: event_id(5),
+ },
+ RadrootsOrderIssue::DecisionPayloadInvalid {
+ event_id: event_id(2),
+ },
+ RadrootsOrderIssue::MissingRequest,
+ ];
+ assert_eq!(
+ super::projection_issue_event_ids(&order_issues),
+ vec![event_id(2), event_id(3), event_id(5), event_id(9)]
+ );
+ order_issues.sort_by(super::order_issue_sort_key);
+ assert!(matches!(
+ order_issues[0],
+ RadrootsOrderIssue::MissingRequest
+ ));
+ assert!(matches!(
+ order_issues[1],
+ RadrootsOrderIssue::DecisionPayloadInvalid { .. }
+ ));
+ assert!(matches!(
+ order_issues[2],
+ RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. }
+ ));
+ assert!(matches!(
+ order_issues[3],
+ RadrootsOrderIssue::ForkedLifecycle { .. }
+ ));
+
+ let mut tied_order_issues = vec![
+ RadrootsOrderIssue::DecisionPayloadInvalid {
+ event_id: event_id(8),
+ },
+ RadrootsOrderIssue::DecisionPayloadInvalid {
+ event_id: event_id(7),
+ },
+ ];
+ tied_order_issues.sort_by(super::order_issue_sort_key);
+ let RadrootsOrderIssue::DecisionPayloadInvalid {
+ event_id: issue_event_id,
+ } = &tied_order_issues[0]
+ else {
+ panic!("expected decision issue");
+ };
+ assert_eq!(issue_event_id, &event_id(7));
+
+ let mut inventory_issues = vec![
+ RadrootsListingInventoryAccountingIssue::OverReserved {
+ bin_id: bin_id("bin-2"),
+ available_count: 1,
+ reserved_count: 2,
+ event_ids: vec![event_id(8)],
+ },
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id: bin_id("bin-1"),
+ event_ids: vec![event_id(7)],
+ },
+ RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ bin_id: bin_id("bin-3"),
+ event_ids: vec![event_id(6)],
+ },
+ RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ order_id: order_id("order-1"),
+ event_ids: vec![event_id(5)],
+ },
+ ];
+ inventory_issues.sort_by(super::inventory_issue_sort_key);
+ assert!(matches!(
+ inventory_issues[0],
+ RadrootsListingInventoryAccountingIssue::InvalidOrder { .. }
+ ));
+ assert!(matches!(
+ inventory_issues[1],
+ RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { .. }
+ ));
+ assert!(matches!(
+ inventory_issues[2],
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { .. }
+ ));
+ assert!(matches!(
+ inventory_issues[3],
+ RadrootsListingInventoryAccountingIssue::OverReserved { .. }
+ ));
+
+ let mut tied_inventory_issues = vec![
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id: bin_id("bin-2"),
+ event_ids: vec![event_id(9)],
+ },
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id: bin_id("bin-1"),
+ event_ids: vec![event_id(8)],
+ },
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id: bin_id("bin-1"),
+ event_ids: vec![event_id(7)],
+ },
+ ];
+ tied_inventory_issues.sort_by(super::inventory_issue_sort_key);
+ assert_eq!(
+ super::inventory_issue_id(&tied_inventory_issues[0]),
+ "bin-1"
+ );
+ assert_eq!(
+ super::inventory_issue_event_ids(&tied_inventory_issues[0]),
+ &[event_id(7)]
+ );
+
+ let invalid = super::invalid_projection(
+ &order_id("order-1"),
+ Some(&request_record()),
+ vec![RadrootsOrderIssue::MissingRequest],
+ );
+ assert_eq!(invalid.last_event_id, Some(event_id(1)));
+ }
+
+ #[test]
+ fn order_issue_rank_and_event_id_helpers_cover_every_issue_variant() {
+ let id = event_id(42);
+ let event_ids = vec![id.clone()];
+ let issues = vec![
+ RadrootsOrderIssue::MissingRequest,
+ RadrootsOrderIssue::MultipleRequests {
+ event_ids: event_ids.clone(),
+ },
+ RadrootsOrderIssue::RequestPayloadInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RequestOrderIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RequestAuthorMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RequestListingAddressInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RequestSellerListingMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionPayloadInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionOrderIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionAuthorMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionCounterpartyMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionBuyerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionSellerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionListingAddressInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionListingMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionRootMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionPreviousMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionMissingInventoryCommitments {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionInventoryCommitmentMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::DecisionMissingReason {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ConflictingDecisions {
+ event_ids: event_ids.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalPayloadInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalOrderIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalAuthorMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalCounterpartyMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalBuyerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalSellerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalListingAddressInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalListingMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalRootMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionProposalPreviousMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionWithoutProposal {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionPayloadInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionOrderIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionAuthorMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionCounterpartyMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionBuyerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionSellerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionListingAddressInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionListingMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionRootMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionPreviousMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::RevisionDecisionRevisionIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationWithoutCancellableOrder {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationPayloadInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationOrderIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationAuthorMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationCounterpartyMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationBuyerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationSellerMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationListingAddressInvalid {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationListingMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationRootMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::CancellationPreviousMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ForkedLifecycle { event_ids },
+ ];
+
+ for (rank, issue) in issues.iter().enumerate() {
+ assert_eq!(super::order_issue_rank(issue), rank as u8);
+ }
+ assert_eq!(super::projection_issue_event_ids(&issues), vec![id]);
+ }
+
+ #[test]
+ fn inventory_issue_helpers_cover_all_issue_variants() {
+ let id = event_id(9);
+ let issues = vec![
+ RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ order_id: order_id("order-1"),
+ event_ids: vec![id.clone()],
+ },
+ RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ bin_id: bin_id("bin-1"),
+ event_ids: vec![id.clone()],
+ },
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id: bin_id("bin-2"),
+ event_ids: vec![id.clone()],
+ },
+ RadrootsListingInventoryAccountingIssue::OverReserved {
+ bin_id: bin_id("bin-3"),
+ available_count: 1,
+ reserved_count: 2,
+ event_ids: vec![id.clone()],
+ },
+ ];
+
+ assert_eq!(super::inventory_issue_rank(&issues[0]), 0);
+ assert_eq!(super::inventory_issue_rank(&issues[1]), 1);
+ assert_eq!(super::inventory_issue_rank(&issues[2]), 2);
+ assert_eq!(super::inventory_issue_rank(&issues[3]), 3);
+ assert_eq!(super::inventory_issue_id(&issues[0]), "order-1");
+ assert_eq!(super::inventory_issue_id(&issues[1]), "bin-1");
+ assert_eq!(super::inventory_issue_id(&issues[2]), "bin-2");
+ assert_eq!(super::inventory_issue_id(&issues[3]), "bin-3");
+ for issue in &issues {
+ assert_eq!(super::inventory_issue_event_ids(issue), &[id.clone()]);
+ }
+ }
+
+ #[test]
+ fn reducer_reports_missing_request_for_each_non_request_input_family() {
+ let decision_only = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: vec![accepted_decision()],
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_order_issue_kind(&decision_only.issues, RadrootsOrderIssue::MissingRequest);
+
+ let proposal_only = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: vec![revision_proposal()],
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_order_issue_kind(&proposal_only.issues, RadrootsOrderIssue::MissingRequest);
+
+ let revision_decision_only = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: vec![accepted_revision_decision()],
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_order_issue_kind(
+ &revision_decision_only.issues,
+ RadrootsOrderIssue::MissingRequest,
+ );
+
+ let cancellation_only = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: vec![cancellation(event_id(1))],
+ },
+ );
+ assert_order_issue_kind(
+ &cancellation_only.issues,
+ RadrootsOrderIssue::MissingRequest,
+ );
+ }
+
+ #[test]
+ fn reducer_reports_multiple_valid_cancellations_as_forked_lifecycle() {
+ let mut second_cancellation = cancellation(event_id(1));
+ second_cancellation.event_id = event_id(6);
+ let projection = reduce(
+ Vec::new(),
+ Vec::new(),
+ Vec::new(),
+ vec![cancellation(event_id(1)), second_cancellation],
+ );
+
+ assert_order_issue_kind(
+ &projection.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+ assert_eq!(projection.last_event_id, Some(event_id(6)));
+ }
+
+ #[test]
+ fn inventory_accounting_private_helpers_cover_merge_sort_and_overflow_paths() {
+ let (bins, issues) = super::normalized_listing_inventory_bins(vec![
+ RadrootsListingInventoryBinAvailability {
+ bin_id: bin_id("bin-1"),
+ available_count: 1,
+ },
+ RadrootsListingInventoryBinAvailability {
+ bin_id: bin_id("bin-1"),
+ available_count: 2,
+ },
+ ]);
+ assert_eq!(issues, Vec::new());
+ assert_eq!(bins[0].available_count, 3);
+ assert_eq!(bins[0].remaining_count, 3);
+
+ let mut overflow_bin = super::RadrootsListingInventoryBinAccounting {
+ bin_id: bin_id("bin-overflow"),
+ available_count: u64::MAX,
+ accepted_reserved_count: u64::MAX,
+ remaining_count: u64::MAX,
+ over_reserved: false,
+ accepted_orders: Vec::new(),
+ };
+ let mut overflow_issues = Vec::new();
+ super::add_inventory_reservation_event(
+ &mut overflow_bin,
+ &order_id("order-overflow"),
+ &event_id(90),
+ 1,
+ &mut overflow_issues,
+ );
+ assert_inventory_issue_kind(
+ &overflow_issues,
+ RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ bin_id: bin_id("bin-overflow"),
+ event_ids: Vec::new(),
+ },
+ );
+
+ let mut sorting_bin = super::RadrootsListingInventoryBinAccounting {
+ bin_id: bin_id("bin-sort"),
+ available_count: 1,
+ accepted_reserved_count: 2,
+ remaining_count: 1,
+ over_reserved: false,
+ accepted_orders: vec![
+ super::RadrootsListingInventoryOrderReservation {
+ order_id: order_id("order-2"),
+ agreement_event_id: event_id(92),
+ bin_count: 1,
+ },
+ super::RadrootsListingInventoryOrderReservation {
+ order_id: order_id("order-1"),
+ agreement_event_id: event_id(91),
+ bin_count: 1,
+ },
+ super::RadrootsListingInventoryOrderReservation {
+ order_id: order_id("order-1"),
+ agreement_event_id: event_id(90),
+ bin_count: 1,
+ },
+ ],
+ };
+ let mut finish_issues = Vec::new();
+ super::finish_inventory_accounting_bins(
+ core::slice::from_mut(&mut sorting_bin),
+ &mut finish_issues,
+ );
+ assert_eq!(sorting_bin.remaining_count, 0);
+ assert!(sorting_bin.over_reserved);
+ assert_eq!(sorting_bin.accepted_orders[0].order_id, order_id("order-1"));
+ assert_eq!(
+ sorting_bin.accepted_orders[0].agreement_event_id,
+ event_id(90)
+ );
+ assert_inventory_issue_kind(
+ &finish_issues,
+ RadrootsListingInventoryAccountingIssue::OverReserved {
+ bin_id: bin_id("bin-sort"),
+ available_count: 1,
+ reserved_count: 2,
+ event_ids: Vec::new(),
+ },
+ );
+ }
+
+ #[test]
+ fn reducer_reports_missing_duplicate_and_forked_lifecycles() {
+ let missing = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_eq!(missing.status, RadrootsOrderStatus::Missing);
+
+ let missing_request = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: vec![accepted_decision()],
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_order_issue_kind(&missing_request.issues, RadrootsOrderIssue::MissingRequest);
+
+ let mut duplicate_request = request_record();
+ duplicate_request.event_id = event_id(6);
+ let duplicate = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record(), duplicate_request],
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_order_issue_kind(
+ &duplicate.issues,
+ RadrootsOrderIssue::MultipleRequests {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let mut second_decision = declined_decision();
+ second_decision.event_id = event_id(6);
+ let conflicting = reduce(
+ vec![accepted_decision(), second_decision],
+ vec![],
+ vec![],
+ vec![],
+ );
+ assert_order_issue_kind(
+ &conflicting.issues,
+ RadrootsOrderIssue::ConflictingDecisions {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let without_proposal = reduce(
+ Vec::new(),
+ Vec::new(),
+ vec![accepted_revision_decision()],
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &without_proposal.issues,
+ RadrootsOrderIssue::RevisionDecisionWithoutProposal {
+ event_id: event_id(4),
+ },
+ );
+
+ let mut second_proposal = revision_proposal();
+ second_proposal.event_id = event_id(6);
+ let multiple_proposals = reduce(
+ Vec::new(),
+ vec![revision_proposal(), second_proposal],
+ vec![],
+ vec![],
+ );
+ assert_order_issue_kind(
+ &multiple_proposals.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let mut second_revision_decision = accepted_revision_decision();
+ second_revision_decision.event_id = event_id(7);
+ let multiple_revision_decisions = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ vec![accepted_revision_decision(), second_revision_decision],
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &multiple_revision_decisions.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let decided_then_revised = reduce(
+ vec![accepted_decision()],
+ vec![revision_proposal()],
+ Vec::new(),
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &decided_then_revised.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let decided_then_revision_decision = reduce(
+ vec![accepted_decision()],
+ Vec::new(),
+ vec![accepted_revision_decision()],
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &decided_then_revision_decision.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+ }
+
+ #[test]
+ fn reducer_covers_revision_and_cancellation_edge_paths() {
+ let pending_revision = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ Vec::new(),
+ Vec::new(),
+ );
+ assert_eq!(pending_revision.status, RadrootsOrderStatus::Requested);
+ assert_eq!(
+ pending_revision.pending_revision_event_id,
+ Some(event_id(3))
+ );
+ assert_eq!(
+ pending_revision.economics.expect("pending economics").items[0].bin_count,
+ 1
+ );
+
+ let mut bad_proposal_previous = revision_proposal();
+ bad_proposal_previous.prev_event_id = event_id(8);
+ bad_proposal_previous.payload.prev_event_id = event_id(8);
+ let bad_proposal = reduce(
+ Vec::new(),
+ vec![bad_proposal_previous],
+ Vec::new(),
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &bad_proposal.issues,
+ RadrootsOrderIssue::RevisionProposalPreviousMismatch {
+ event_id: event_id(3),
+ },
+ );
+
+ let mut bad_decision_previous = accepted_revision_decision();
+ bad_decision_previous.prev_event_id = event_id(8);
+ bad_decision_previous.payload.prev_event_id = event_id(8);
+ let bad_decision = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ vec![bad_decision_previous],
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &bad_decision.issues,
+ RadrootsOrderIssue::RevisionDecisionPreviousMismatch {
+ event_id: event_id(4),
+ },
+ );
+
+ let mut bad_revision_id = accepted_revision_decision();
+ bad_revision_id.payload.revision_id = revision_id("revision-2");
+ let bad_revision = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ vec![bad_revision_id],
+ Vec::new(),
+ );
+ assert_order_issue_kind(
+ &bad_revision.issues,
+ RadrootsOrderIssue::RevisionDecisionRevisionIdMismatch {
+ event_id: event_id(4),
+ },
+ );
+
+ let mut declined_revision = accepted_revision_decision();
+ declined_revision.payload.decision = RadrootsOrderRevisionOutcome::Declined {
+ reason: "too late".into(),
+ };
+ let declined = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ vec![declined_revision],
+ Vec::new(),
+ );
+ assert_eq!(declined.status, RadrootsOrderStatus::Declined);
+ assert_eq!(declined.pending_revision_event_id, Some(event_id(3)));
+
+ let cancellation_after_decision = reduce(
+ vec![declined_decision()],
+ Vec::new(),
+ Vec::new(),
+ vec![cancellation(event_id(2))],
+ );
+ assert_order_issue_kind(
+ &cancellation_after_decision.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let cancellation_after_revision_decision = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ vec![accepted_revision_decision()],
+ vec![cancellation(event_id(4))],
+ );
+ assert_order_issue_kind(
+ &cancellation_after_revision_decision.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let mut second_proposal = revision_proposal();
+ second_proposal.event_id = event_id(6);
+ let cancellation_after_multiple_proposals = reduce(
+ Vec::new(),
+ vec![revision_proposal(), second_proposal],
+ Vec::new(),
+ vec![cancellation(event_id(3))],
+ );
+ assert_order_issue_kind(
+ &cancellation_after_multiple_proposals.issues,
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: Vec::new(),
+ },
+ );
+
+ let cancellation_previous_mismatch = reduce(
+ Vec::new(),
+ vec![revision_proposal()],
+ Vec::new(),
+ vec![cancellation(event_id(1))],
+ );
+ assert_order_issue_kind(
+ &cancellation_previous_mismatch.issues,
+ RadrootsOrderIssue::CancellationPreviousMismatch {
+ event_id: event_id(5),
+ },
+ );
+ }
+
+ #[test]
+ fn reducer_validators_report_request_and_decision_issue_kinds() {
+ assert_request_issue(
+ |request| request.payload.items.clear(),
+ RadrootsOrderIssue::RequestPayloadInvalid {
+ event_id: event_id(1),
+ },
+ );
+ assert_request_issue(
+ |request| request.payload.order_id = order_id("order-2"),
+ RadrootsOrderIssue::RequestOrderIdMismatch {
+ event_id: event_id(1),
+ },
+ );
+ assert_request_issue(
+ |request| request.author_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::RequestAuthorMismatch {
+ event_id: event_id(1),
+ },
+ );
+ assert_request_issue(
+ |request| request.payload.listing_addr = draft_listing_addr(),
+ RadrootsOrderIssue::RequestListingAddressInvalid {
+ event_id: event_id(1),
+ },
+ );
+ assert_request_issue(
+ |request| request.payload.seller_pubkey = public_key(OTHER),
+ RadrootsOrderIssue::RequestSellerListingMismatch {
+ event_id: event_id(1),
+ },
+ );
+
+ assert_decision_issue(
+ |decision| {
+ decision.payload.decision = RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: Vec::new(),
+ };
+ },
+ RadrootsOrderIssue::DecisionMissingInventoryCommitments {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| {
+ decision.payload.decision =
+ RadrootsOrderDecisionOutcome::Declined { reason: " ".into() };
+ },
+ RadrootsOrderIssue::DecisionMissingReason {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.payload.order_id = order_id("order-2"),
+ RadrootsOrderIssue::DecisionOrderIdMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.author_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::DecisionAuthorMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.counterparty_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::DecisionCounterpartyMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.payload.buyer_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::DecisionBuyerMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.payload.seller_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::DecisionSellerMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.payload.listing_addr = draft_listing_addr(),
+ RadrootsOrderIssue::DecisionListingAddressInvalid {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.payload.listing_addr = other_seller_listing_addr(),
+ RadrootsOrderIssue::DecisionListingMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.root_event_id = event_id(8),
+ RadrootsOrderIssue::DecisionRootMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| decision.prev_event_id = event_id(8),
+ RadrootsOrderIssue::DecisionPreviousMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_decision_issue(
+ |decision| {
+ if let RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments,
+ } = &mut decision.payload.decision
+ {
+ inventory_commitments[0].bin_count = 1;
+ }
+ },
+ RadrootsOrderIssue::DecisionInventoryCommitmentMismatch {
+ event_id: event_id(2),
+ },
+ );
+ }
+
+ #[test]
+ fn reducer_validators_report_revision_and_cancellation_issue_kinds() {
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.items.clear(),
+ RadrootsOrderIssue::RevisionProposalPayloadInvalid {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.order_id = order_id("order-2"),
+ RadrootsOrderIssue::RevisionProposalOrderIdMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.author_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::RevisionProposalAuthorMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.counterparty_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::RevisionProposalCounterpartyMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.buyer_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::RevisionProposalBuyerMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.seller_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::RevisionProposalSellerMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.listing_addr = draft_listing_addr(),
+ RadrootsOrderIssue::RevisionProposalListingAddressInvalid {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.listing_addr = other_seller_listing_addr(),
+ RadrootsOrderIssue::RevisionProposalListingMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.root_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionProposalRootMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.root_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionProposalRootMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.prev_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionProposalPreviousMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.prev_event_id = event_id(3),
+ RadrootsOrderIssue::RevisionProposalPreviousMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_revision_proposal_issue(
+ |proposal| proposal.payload.prev_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionProposalPreviousMismatch {
+ event_id: event_id(3),
+ },
+ );
+
+ assert_revision_decision_issue(
+ |decision| {
+ decision.payload.decision =
+ RadrootsOrderRevisionOutcome::Declined { reason: " ".into() };
+ },
+ RadrootsOrderIssue::RevisionDecisionPayloadInvalid {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.order_id = order_id("order-2"),
+ RadrootsOrderIssue::RevisionDecisionOrderIdMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.author_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::RevisionDecisionAuthorMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.counterparty_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::RevisionDecisionCounterpartyMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.buyer_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::RevisionDecisionBuyerMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.seller_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::RevisionDecisionSellerMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.listing_addr = draft_listing_addr(),
+ RadrootsOrderIssue::RevisionDecisionListingAddressInvalid {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.listing_addr = other_seller_listing_addr(),
+ RadrootsOrderIssue::RevisionDecisionListingMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.root_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionDecisionRootMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.root_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionDecisionRootMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.prev_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionDecisionPreviousMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.prev_event_id = event_id(4),
+ RadrootsOrderIssue::RevisionDecisionPreviousMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_revision_decision_issue(
+ |decision| decision.payload.prev_event_id = event_id(8),
+ RadrootsOrderIssue::RevisionDecisionPreviousMismatch {
+ event_id: event_id(4),
+ },
+ );
+
+ assert_cancellation_issue(
+ |cancellation| cancellation.payload.reason = " ".into(),
+ RadrootsOrderIssue::CancellationPayloadInvalid {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.payload.order_id = order_id("order-2"),
+ RadrootsOrderIssue::CancellationOrderIdMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.author_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::CancellationAuthorMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.counterparty_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::CancellationCounterpartyMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.payload.buyer_pubkey = public_key(SELLER),
+ RadrootsOrderIssue::CancellationBuyerMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.payload.seller_pubkey = public_key(BUYER),
+ RadrootsOrderIssue::CancellationSellerMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.payload.listing_addr = draft_listing_addr(),
+ RadrootsOrderIssue::CancellationListingAddressInvalid {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.payload.listing_addr = other_seller_listing_addr(),
+ RadrootsOrderIssue::CancellationListingMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.root_event_id = event_id(8),
+ RadrootsOrderIssue::CancellationRootMismatch {
+ event_id: event_id(5),
+ },
+ );
+ assert_cancellation_issue(
+ |cancellation| cancellation.prev_event_id = event_id(5),
+ RadrootsOrderIssue::CancellationPreviousMismatch {
+ event_id: event_id(5),
+ },
+ );
+ }
+
+ #[test]
+ fn reducer_reports_invalid_records_from_all_non_request_families() {
+ let mut bad_request = request_record();
+ bad_request.payload.order_id = order_id("order-2");
+ let invalid_request = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![bad_request],
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_order_issue_kind(
+ &invalid_request.issues,
+ RadrootsOrderIssue::RequestOrderIdMismatch {
+ event_id: event_id(1),
+ },
+ );
+
+ let mut bad_decision = accepted_decision();
+ bad_decision.payload.order_id = order_id("order-2");
+ let mut bad_proposal = revision_proposal();
+ bad_proposal.payload.order_id = order_id("order-2");
+ let mut bad_revision_decision = accepted_revision_decision();
+ bad_revision_decision.payload.order_id = order_id("order-2");
+ let mut bad_cancellation = cancellation(event_id(1));
+ bad_cancellation.payload.order_id = order_id("order-2");
+ let invalid_non_requests = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record()],
+ decisions: vec![bad_decision],
+ revision_proposals: vec![bad_proposal],
+ revision_decisions: vec![bad_revision_decision],
+ cancellations: vec![bad_cancellation],
+ },
+ );
+
+ assert_order_issue_kind(
+ &invalid_non_requests.issues,
+ RadrootsOrderIssue::DecisionOrderIdMismatch {
+ event_id: event_id(2),
+ },
+ );
+ assert_order_issue_kind(
+ &invalid_non_requests.issues,
+ RadrootsOrderIssue::RevisionProposalOrderIdMismatch {
+ event_id: event_id(3),
+ },
+ );
+ assert_order_issue_kind(
+ &invalid_non_requests.issues,
+ RadrootsOrderIssue::RevisionDecisionOrderIdMismatch {
+ event_id: event_id(4),
+ },
+ );
+ assert_order_issue_kind(
+ &invalid_non_requests.issues,
+ RadrootsOrderIssue::CancellationOrderIdMismatch {
+ event_id: event_id(5),
+ },
+ );
+ }
+
+ #[test]
+ fn inventory_accounting_reports_invalid_unknown_overreserved_and_terminal_orders() {
+ let mut unknown_bin_request = request_record();
+ unknown_bin_request.event_id = event_id(40);
+ unknown_bin_request.payload.order_id = order_id("order-4");
+ unknown_bin_request.payload.items[0].bin_id = bin_id("bin-missing");
+ unknown_bin_request.payload.economics.items[0].bin_id = bin_id("bin-missing");
+
+ let mut unknown_bin_decision = accepted_decision();
+ unknown_bin_decision.event_id = event_id(41);
+ unknown_bin_decision.root_event_id = event_id(40);
+ unknown_bin_decision.prev_event_id = event_id(40);
+ unknown_bin_decision.payload.order_id = order_id("order-4");
+ if let RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments,
+ } = &mut unknown_bin_decision.payload.decision
+ {
+ inventory_commitments[0].bin_id = bin_id("bin-missing");
+ }
+
+ let mut declined_request = request_record();
+ declined_request.event_id = event_id(20);
+ declined_request.payload.order_id = order_id("order-2");
+ let mut terminal_decline = declined_decision();
+ terminal_decline.event_id = event_id(21);
+ terminal_decline.root_event_id = event_id(20);
+ terminal_decline.prev_event_id = event_id(20);
+ terminal_decline.payload.order_id = order_id("order-2");
+
+ let mut cancelled_request = request_record();
+ cancelled_request.event_id = event_id(30);
+ cancelled_request.payload.order_id = order_id("order-3");
+ let mut terminal_cancellation = cancellation(event_id(30));
+ terminal_cancellation.event_id = event_id(31);
+ terminal_cancellation.root_event_id = event_id(30);
+ terminal_cancellation.payload.order_id = order_id("order-3");
+
+ let projection = reduce_listing_inventory_accounting(
+ &listing_addr(),
+ &event_id(9),
+ RadrootsListingInventoryAccountingInputs {
+ bins: vec![
+ RadrootsListingInventoryBinAvailability {
+ bin_id: bin_id("bin-1"),
+ available_count: 1,
+ },
+ RadrootsListingInventoryBinAvailability {
+ bin_id: bin_id("bin-overflow"),
+ available_count: u64::MAX,
+ },
+ RadrootsListingInventoryBinAvailability {
+ bin_id: bin_id("bin-overflow"),
+ available_count: 1,
+ },
+ ],
+ requests: vec![
+ request_record(),
+ unknown_bin_request,
+ declined_request,
+ cancelled_request,
+ ],
+ decisions: vec![accepted_decision(), unknown_bin_decision, terminal_decline],
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: vec![terminal_cancellation],
+ },
+ );
+
+ assert_eq!(projection.declined_order_ids, vec![order_id("order-2")]);
+ assert_eq!(projection.cancelled_order_ids, vec![order_id("order-3")]);
+ assert_inventory_issue_kind(
+ &projection.issues,
+ RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ bin_id: bin_id("bin-overflow"),
+ event_ids: Vec::new(),
+ },
+ );
+ assert_inventory_issue_kind(
+ &projection.issues,
+ RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id: bin_id("bin-missing"),
+ event_ids: Vec::new(),
+ },
+ );
+ assert_inventory_issue_kind(
+ &projection.issues,
+ RadrootsListingInventoryAccountingIssue::OverReserved {
+ bin_id: bin_id("bin-1"),
+ available_count: 0,
+ reserved_count: 0,
+ event_ids: Vec::new(),
+ },
+ );
+
+ let invalid_without_request = reduce_listing_inventory_accounting(
+ &listing_addr(),
+ &event_id(9),
+ RadrootsListingInventoryAccountingInputs {
+ bins: Vec::<RadrootsListingInventoryBinAvailability>::new(),
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: vec![accepted_decision()],
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_eq!(invalid_without_request.invalid_event_ids, vec![event_id(2)]);
+ assert_inventory_issue_kind(
+ &invalid_without_request.issues,
+ RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ order_id: order_id("order-1"),
+ event_ids: Vec::new(),
+ },
+ );
+
+ let invalid_revision_streams = reduce_listing_inventory_accounting(
+ &listing_addr(),
+ &event_id(9),
+ RadrootsListingInventoryAccountingInputs {
+ bins: Vec::<RadrootsListingInventoryBinAvailability>::new(),
+ requests: Vec::<RadrootsOrderRequestRecord>::new(),
+ decisions: vec![accepted_decision()],
+ revision_proposals: vec![revision_proposal()],
+ revision_decisions: vec![accepted_revision_decision()],
+ cancellations: vec![cancellation(event_id(3))],
+ },
+ );
+ assert_eq!(
+ invalid_revision_streams.invalid_event_ids,
+ vec![event_id(2), event_id(3), event_id(4), event_id(5)]
+ );
+ assert_inventory_issue_kind(
+ &invalid_revision_streams.issues,
+ RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ order_id: order_id("order-1"),
+ event_ids: Vec::new(),
+ },
+ );
+ }
+
+ #[test]
fn reducer_projects_requested_order() {
let projection = reduce(Vec::new(), Vec::new(), Vec::new(), Vec::new());
diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs
@@ -603,16 +603,11 @@ fn validate_required_str(
fn validate_inline_proof_base64(value: &str) -> Result<(), RadrootsValidationReceiptError> {
validate_required_str(value, "proof.inline_proof_base64")?;
- let decoded = base64::engine::general_purpose::STANDARD
+ base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|_| {
RadrootsValidationReceiptError::InvalidProofMetadata("proof.inline_proof_base64")
})?;
- if decoded.is_empty() || base64::engine::general_purpose::STANDARD.encode(&decoded) != value {
- return Err(RadrootsValidationReceiptError::InvalidProofMetadata(
- "proof.inline_proof_base64",
- ));
- }
Ok(())
}
@@ -688,12 +683,15 @@ mod tests {
RadrootsValidationReceiptExpectedBinding, RadrootsValidationReceiptProof,
RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult,
RadrootsValidationReceiptStatement, RadrootsValidationReceiptType,
- validation_receipt_canonical_content, validation_receipt_content_from_str,
- validation_receipt_event_build, validation_receipt_from_event,
- validation_receipt_public_values_hash_hex, validation_receipt_tags,
+ TAG_VALIDATION_RECEIPT_EVENT_SET_ROOT, TAG_VALIDATION_RECEIPT_PROOF_SYSTEM,
+ TAG_VALIDATION_RECEIPT_PUBLIC_VALUES_HASH, TAG_VALIDATION_RECEIPT_RECEIPT_TYPE,
+ TAG_VALIDATION_RECEIPT_REDUCER_OUTPUT_ROOT, validation_receipt_canonical_content,
+ validation_receipt_content_from_str, validation_receipt_event_build,
+ validation_receipt_from_event, validation_receipt_public_values_hash_hex,
+ validation_receipt_tags, validation_receipt_tags_from_tags,
verify_validation_receipt_event,
};
- use radroots_events::{RadrootsNostrEvent, kinds::KIND_TRADE_VALIDATION_RECEIPT};
+ use radroots_events::{RadrootsNostrEvent, kinds::KIND_TRADE_VALIDATION_RECEIPT, tags::TAG_D};
fn hash32(c: char) -> String {
format!("0x{}", c.to_string().repeat(64))
@@ -762,6 +760,586 @@ mod tests {
}
#[test]
+ fn validation_receipt_labels_cover_all_variants() {
+ assert_eq!(
+ RadrootsValidationReceiptType::ListingValidation.as_str(),
+ "listing_validation"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::TradeTransition.as_str(),
+ "trade_transition"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::InventoryState.as_str(),
+ "inventory_state"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::StateCheckpoint.as_str(),
+ "state_checkpoint"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::from_label("listing_validation"),
+ Some(RadrootsValidationReceiptType::ListingValidation)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::from_label("trade_transition"),
+ Some(RadrootsValidationReceiptType::TradeTransition)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::from_label("inventory_state"),
+ Some(RadrootsValidationReceiptType::InventoryState)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptType::from_label("state_checkpoint"),
+ Some(RadrootsValidationReceiptType::StateCheckpoint)
+ );
+ assert_eq!(RadrootsValidationReceiptType::from_label("unknown"), None);
+
+ assert_eq!(RadrootsValidationReceiptProofSystem::None.as_str(), "none");
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Core.as_str(),
+ "sp1_core"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Compressed.as_str(),
+ "sp1_compressed"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Groth16.as_str(),
+ "sp1_groth16"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Plonk.as_str(),
+ "sp1_plonk"
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::from_label("none"),
+ Some(RadrootsValidationReceiptProofSystem::None)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::from_label("sp1_core"),
+ Some(RadrootsValidationReceiptProofSystem::Sp1Core)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::from_label("sp1_compressed"),
+ Some(RadrootsValidationReceiptProofSystem::Sp1Compressed)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::from_label("sp1_groth16"),
+ Some(RadrootsValidationReceiptProofSystem::Sp1Groth16)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::from_label("sp1_plonk"),
+ Some(RadrootsValidationReceiptProofSystem::Sp1Plonk)
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::from_label("unknown"),
+ None
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::None.expected_mode(),
+ None
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Core.expected_mode(),
+ Some("core")
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Compressed.expected_mode(),
+ Some("compressed")
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Groth16.expected_mode(),
+ Some("groth16")
+ );
+ assert_eq!(
+ RadrootsValidationReceiptProofSystem::Sp1Plonk.expected_mode(),
+ Some("plonk")
+ );
+ }
+
+ #[test]
+ fn validation_receipt_validate_rejects_core_field_errors() {
+ let mut receipt = sample_validation_receipt();
+ receipt.version = 2;
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("version"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.domain = "other.domain".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("domain"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.statement.statement_type = RadrootsValidationReceiptType::ListingValidation;
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "statement.type"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.changed_records_root = "0x1".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "changed_records_root"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.event_set_root = format!("zz{}", "1".repeat(64));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "event_set_root"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.public_values_hash = format!("0x{}", "A".repeat(64));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "public_values_hash"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.error_bitmap = "0x1".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("error_bitmap"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.error_bitmap = format!("zz{}", "0".repeat(32));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("error_bitmap"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.error_bitmap = format!("0x{}", "A".repeat(32));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("error_bitmap"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.statement.listing_event_id = "bad".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "statement.listing_event_id"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.statement.root_event_id = "g".repeat(64);
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "statement.root_event_id"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.error_bitmap = "0x00000000000000000000000000000001".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("error_bitmap"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.result = RadrootsValidationReceiptResult::Invalid;
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField("error_bitmap"))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.result = RadrootsValidationReceiptResult::Invalid;
+ receipt.error_bitmap = "0x00000000000000000000000000000001".to_string();
+ receipt
+ .validate()
+ .expect("invalid result with nonzero bitmap");
+ }
+
+ #[test]
+ fn validation_receipt_proof_validation_covers_identity_modes_and_material_errors() {
+ let mut receipt = sample_validation_receipt();
+ receipt.proof.mode = Some("core".to_string());
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.system"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.proof.program_hash = Some(hash32('a'));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.system"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.proof.proof_reference = Some(format!("radroots-proof://sha256/{}", "1".repeat(64)));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.system"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.proof.verifying_key_hash = Some(hash32('b'));
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.system"
+ ))
+ );
+
+ let mut missing_program = sample_sp1_reference_receipt();
+ missing_program.proof.program_hash = None;
+ assert_eq!(
+ missing_program.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.program_hash"
+ ))
+ );
+
+ let mut missing_verifying_key = sample_sp1_reference_receipt();
+ missing_verifying_key.proof.verifying_key_hash = None;
+ assert_eq!(
+ missing_verifying_key.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.verifying_key_hash"
+ ))
+ );
+
+ let mut wrong_mode = sample_sp1_reference_receipt();
+ wrong_mode.proof.mode = Some("compressed".to_string());
+ assert_eq!(
+ wrong_mode.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.mode"
+ ))
+ );
+
+ let mut empty_reference = sample_sp1_reference_receipt();
+ empty_reference.proof.proof_reference = Some(" ".to_string());
+ assert_eq!(
+ empty_reference.validate(),
+ Err(RadrootsValidationReceiptError::EmptyField(
+ "proof.proof_reference"
+ ))
+ );
+
+ let mut compressed = sample_sp1_reference_receipt();
+ compressed.proof.system = RadrootsValidationReceiptProofSystem::Sp1Compressed;
+ compressed.proof.mode = Some("compressed".to_string());
+ compressed.validate().expect("compressed proof metadata");
+
+ let mut groth16 = sample_sp1_reference_receipt();
+ groth16.proof.system = RadrootsValidationReceiptProofSystem::Sp1Groth16;
+ groth16.proof.mode = Some("groth16".to_string());
+ groth16.validate().expect("groth16 proof metadata");
+
+ let mut plonk = sample_sp1_reference_receipt();
+ plonk.proof.system = RadrootsValidationReceiptProofSystem::Sp1Plonk;
+ plonk.proof.mode = Some("plonk".to_string());
+ plonk.validate().expect("plonk proof metadata");
+ }
+
+ #[test]
+ fn validation_receipt_tag_parser_rejects_invalid_shapes_and_labels() {
+ let tags = validation_receipt_tags("order-1", &sample_validation_receipt()).unwrap();
+
+ let mut duplicate_order = tags.clone();
+ duplicate_order.push(vec![TAG_D.to_string(), "other-order".to_string()]);
+ assert_eq!(
+ validation_receipt_tags_from_tags(&duplicate_order),
+ Err(RadrootsValidationReceiptError::InvalidTag(TAG_D))
+ );
+
+ let mut malformed_order = tags.clone();
+ malformed_order[0] = vec![TAG_D.to_string()];
+ assert_eq!(
+ validation_receipt_tags_from_tags(&malformed_order),
+ Err(RadrootsValidationReceiptError::InvalidTag(TAG_D))
+ );
+
+ let mut empty_order = tags.clone();
+ empty_order[0][1] = " ".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&empty_order),
+ Err(RadrootsValidationReceiptError::EmptyField(TAG_D))
+ );
+
+ let mut duplicate_listing = tags.clone();
+ duplicate_listing.push(vec![
+ "e".to_string(),
+ event_id('3'),
+ String::new(),
+ String::new(),
+ "listing".to_string(),
+ ]);
+ assert_eq!(
+ validation_receipt_tags_from_tags(&duplicate_listing),
+ Err(RadrootsValidationReceiptError::InvalidTag("listing"))
+ );
+
+ let mut empty_listing = tags.clone();
+ empty_listing[1][1] = " ".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&empty_listing),
+ Err(RadrootsValidationReceiptError::EmptyField("listing"))
+ );
+
+ let mut invalid_listing = tags.clone();
+ invalid_listing[1][1] = "bad".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_listing),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "tags.e.listing"
+ ))
+ );
+
+ let mut invalid_root = tags.clone();
+ invalid_root[2][1] = "g".repeat(64);
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_root),
+ Err(RadrootsValidationReceiptError::InvalidField("tags.e.root"))
+ );
+
+ let mut invalid_target = tags.clone();
+ invalid_target[3][1] = "bad".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_target),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "tags.e.target"
+ ))
+ );
+
+ let mut invalid_event_set = tags.clone();
+ invalid_event_set[4][1] = "bad".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_event_set),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ TAG_VALIDATION_RECEIPT_EVENT_SET_ROOT
+ ))
+ );
+
+ let mut invalid_reducer = tags.clone();
+ invalid_reducer[5][1] = "bad".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_reducer),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ TAG_VALIDATION_RECEIPT_REDUCER_OUTPUT_ROOT
+ ))
+ );
+
+ let mut invalid_public_values = tags.clone();
+ invalid_public_values[6][1] = "bad".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_public_values),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ TAG_VALIDATION_RECEIPT_PUBLIC_VALUES_HASH
+ ))
+ );
+
+ let mut invalid_proof_system = tags.clone();
+ invalid_proof_system[7][1] = "sp1_unknown".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_proof_system),
+ Err(RadrootsValidationReceiptError::InvalidTag(
+ TAG_VALIDATION_RECEIPT_PROOF_SYSTEM
+ ))
+ );
+
+ let mut invalid_receipt_type = tags.clone();
+ invalid_receipt_type[8][1] = "unknown".to_string();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&invalid_receipt_type),
+ Err(RadrootsValidationReceiptError::InvalidTag(
+ TAG_VALIDATION_RECEIPT_RECEIPT_TYPE
+ ))
+ );
+ }
+
+ #[test]
+ fn validation_receipt_verifier_rejects_each_tag_mismatch() {
+ let mut event = sample_validation_receipt_event();
+ event.tags[1][1] = event_id('3');
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch(
+ "listing_event_id"
+ ))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[2][1] = event_id('3');
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch("root_event_id"))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[3][1] = event_id('3');
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch(
+ "target_event_id"
+ ))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[4][1] = hash32('d');
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch(
+ "event_set_root"
+ ))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[5][1] = hash32('d');
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch(
+ "reducer_output_root"
+ ))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[6][1] = hash32('d');
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch(
+ "public_values_hash"
+ ))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[7][1] = "sp1_core".to_string();
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch("proof_system"))
+ );
+
+ let mut event = sample_validation_receipt_event();
+ event.tags[8][1] = "listing_validation".to_string();
+ assert_eq!(
+ validation_receipt_from_event(&event),
+ Err(RadrootsValidationReceiptError::TagMismatch("receipt_type"))
+ );
+ }
+
+ #[test]
+ fn validation_receipt_expected_binding_checks_all_supported_fields() {
+ let event = sample_validation_receipt_event();
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ event_set_root: Some(&hash32('c')),
+ listing_event_id: Some(&event_id('0')),
+ order_id: Some("order-1"),
+ proof_system: Some(RadrootsValidationReceiptProofSystem::None),
+ public_values_hash: Some(&validation_receipt_public_values_hash_hex(
+ br#"{"schema_version":1}"#,
+ )),
+ reducer_output_root: Some(&hash32('4')),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ )
+ .expect("matching expected binding");
+
+ assert_eq!(
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ listing_event_id: Some(&event_id('3')),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ ),
+ Err(RadrootsValidationReceiptError::ExpectedBindingMismatch(
+ "listing_event_id"
+ ))
+ );
+ assert_eq!(
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ event_set_root: Some(&hash32('d')),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ ),
+ Err(RadrootsValidationReceiptError::ExpectedBindingMismatch(
+ "event_set_root"
+ ))
+ );
+ assert_eq!(
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ reducer_output_root: Some(&hash32('d')),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ ),
+ Err(RadrootsValidationReceiptError::ExpectedBindingMismatch(
+ "reducer_output_root"
+ ))
+ );
+ assert_eq!(
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ public_values_hash: Some(&hash32('d')),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ ),
+ Err(RadrootsValidationReceiptError::ExpectedBindingMismatch(
+ "public_values_hash"
+ ))
+ );
+ assert_eq!(
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ proof_system: Some(RadrootsValidationReceiptProofSystem::Sp1Core),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ ),
+ Err(RadrootsValidationReceiptError::ExpectedBindingMismatch(
+ "proof_system"
+ ))
+ );
+ assert_eq!(
+ verify_validation_receipt_event(
+ &event,
+ RadrootsValidationReceiptExpectedBinding {
+ verifying_key_hash: Some(&hash32('b')),
+ ..RadrootsValidationReceiptExpectedBinding::default()
+ },
+ ),
+ Err(RadrootsValidationReceiptError::ExpectedBindingMismatch(
+ "verifying_key_hash"
+ ))
+ );
+ }
+
+ #[test]
fn validation_receipt_round_trips_canonical_payload_and_tags() {
let receipt = sample_validation_receipt();
let content = validation_receipt_canonical_content(&receipt).expect("canonical content");