mod.rs (29685B)
1 #[cfg(all(not(feature = "std"), feature = "json"))] 2 use alloc::{ 3 string::{String, ToString}, 4 vec, 5 vec::Vec, 6 }; 7 8 #[cfg(feature = "json")] 9 use crate::verification::RadrootsSignatureVerifiedEvent; 10 #[cfg(feature = "json")] 11 use radroots_event::{ 12 envelope::EventEnvelope, 13 envelope::kind::is_trade_mutation_event_kind, 14 id::{MutationId, TradeId}, 15 trade::{ 16 TradeMutationEnvelopeV1, TradeMutationKindV1, canonical_trade_mutation_content, 17 trade_mutation_from_canonical_content, 18 }, 19 wire::Nip01EventWireParts, 20 }; 21 #[cfg(feature = "json")] 22 use radroots_identity::PublicKey; 23 24 #[cfg(feature = "json")] 25 const MAX_TRADE_MUTATION_TAGS: usize = 10; 26 27 #[cfg(feature = "json")] 28 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 29 pub enum RadrootsTradeMutationError { 30 CallerStructuralTagForbidden, 31 DuplicateTradeTag, 32 LegacyParentEventTag, 33 MissingParentTag, 34 MissingMutationTag, 35 MissingRootTag, 36 NoncanonicalParentOrder, 37 PartyTagOrderMismatch, 38 UnexpectedParentTag, 39 UnexpectedRootTag, 40 InvalidKind, 41 AuthorMismatch, 42 AuthoredAtMismatch, 43 CanonicalContentMismatch, 44 InvalidIdentifier, 45 InvalidTagShape, 46 UnexpectedTag, 47 ContractTagMismatch, 48 TradeTagMismatch, 49 MutationTagMismatch, 50 RootTagMismatch, 51 ParentTagMismatch, 52 } 53 54 #[cfg(feature = "json")] 55 impl RadrootsTradeMutationError { 56 #[must_use] 57 pub const fn code(self) -> &'static str { 58 match self { 59 Self::CallerStructuralTagForbidden => "caller_structural_tag_forbidden", 60 Self::DuplicateTradeTag => "duplicate_trade_tag", 61 Self::LegacyParentEventTag => "legacy_parent_event_tag", 62 Self::MissingParentTag => "missing_parent_tag", 63 Self::MissingMutationTag => "missing_mutation_tag", 64 Self::MissingRootTag => "missing_root_tag", 65 Self::NoncanonicalParentOrder => "noncanonical_parent_order", 66 Self::PartyTagOrderMismatch => "party_tag_order_mismatch", 67 Self::UnexpectedParentTag => "unexpected_parent_tag", 68 Self::UnexpectedRootTag => "unexpected_root_tag", 69 Self::InvalidKind => "invalid_kind", 70 Self::AuthorMismatch => "author_mismatch", 71 Self::AuthoredAtMismatch => "authored_at_mismatch", 72 Self::CanonicalContentMismatch => "canonical_content_mismatch", 73 Self::InvalidIdentifier => "invalid_identifier", 74 Self::InvalidTagShape => "invalid_tag_shape", 75 Self::UnexpectedTag => "unexpected_tag", 76 Self::ContractTagMismatch => "contract_tag_mismatch", 77 Self::TradeTagMismatch => "trade_tag_mismatch", 78 Self::MutationTagMismatch => "mutation_tag_mismatch", 79 Self::RootTagMismatch => "root_tag_mismatch", 80 Self::ParentTagMismatch => "parent_tag_mismatch", 81 } 82 } 83 } 84 85 #[cfg(feature = "json")] 86 impl core::fmt::Display for RadrootsTradeMutationError { 87 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 88 f.write_str(match self { 89 Self::CallerStructuralTagForbidden => "caller supplied a governed trade tag", 90 Self::DuplicateTradeTag => "trade tag is duplicated", 91 Self::LegacyParentEventTag => "legacy trade parent tag is forbidden", 92 Self::MissingParentTag => "trade parent tag is missing", 93 Self::MissingMutationTag => "trade mutation tag is missing", 94 Self::MissingRootTag => "trade root tag is missing", 95 Self::NoncanonicalParentOrder => "trade parent tags are not canonical", 96 Self::PartyTagOrderMismatch => "trade party tags are not canonical", 97 Self::UnexpectedParentTag => "trade parent tag is unexpected", 98 Self::UnexpectedRootTag => "trade root tag is unexpected", 99 Self::InvalidKind => "trade mutation kind is invalid", 100 Self::AuthorMismatch => "trade mutation author does not match content", 101 Self::AuthoredAtMismatch => "trade mutation timestamp does not match content", 102 Self::CanonicalContentMismatch => "trade mutation content is not canonical", 103 Self::InvalidIdentifier => "trade mutation identifier is invalid", 104 Self::InvalidTagShape => "trade mutation tag shape is invalid", 105 Self::UnexpectedTag => "trade mutation tag is not permitted", 106 Self::ContractTagMismatch => "trade contract tag does not match content", 107 Self::TradeTagMismatch => "trade identifier tag does not match content", 108 Self::MutationTagMismatch => "trade mutation tag does not match content", 109 Self::RootTagMismatch => "trade root tag does not match content", 110 Self::ParentTagMismatch => "trade parent tag does not match content", 111 }) 112 } 113 } 114 115 #[cfg(all(feature = "std", feature = "json"))] 116 impl std::error::Error for RadrootsTradeMutationError {} 117 118 #[cfg(feature = "json")] 119 pub fn trade_mutation_event_build( 120 envelope: TradeMutationEnvelopeV1, 121 ) -> Result<Nip01EventWireParts, RadrootsTradeMutationError> { 122 trade_mutation_event_build_with_extra_tags(envelope, &[]) 123 } 124 125 #[cfg(feature = "json")] 126 pub fn trade_mutation_event_build_with_extra_tags( 127 envelope: TradeMutationEnvelopeV1, 128 extra_tags: &[Vec<String>], 129 ) -> Result<Nip01EventWireParts, RadrootsTradeMutationError> { 130 if extra_tags.iter().any(|tag| { 131 matches!( 132 tag.first().map(String::as_str), 133 Some("contract" | "d" | "x" | "p" | "e") 134 ) 135 }) { 136 return Err(RadrootsTradeMutationError::CallerStructuralTagForbidden); 137 } 138 if let Some(tag) = extra_tags.first() { 139 if tag.is_empty() { 140 return Err(RadrootsTradeMutationError::InvalidTagShape); 141 } 142 return Err(RadrootsTradeMutationError::UnexpectedTag); 143 } 144 let canonical = canonical_trade_mutation_content(envelope) 145 .map_err(|_| RadrootsTradeMutationError::CanonicalContentMismatch)?; 146 let tags = canonical_trade_mutation_tags(&canonical.envelope)?; 147 Ok(Nip01EventWireParts { 148 kind: canonical.envelope.mutation_kind().nostr_kind(), 149 content: canonical.content, 150 tags, 151 }) 152 } 153 154 #[cfg(feature = "json")] 155 /// Structurally parses and validates a trade-mutation event. 156 /// 157 /// This boundary binds the event kind, declared author, timestamp, canonical 158 /// content, and ordered tags. It does not verify the event signature; callers 159 /// that require cryptographic verification must use 160 /// [`trade_mutation_from_verified_event`]. 161 pub fn trade_mutation_from_event( 162 event: &EventEnvelope, 163 ) -> Result<TradeMutationEnvelopeV1, RadrootsTradeMutationError> { 164 validate_trade_mutation_parts( 165 event.kind_u32(), 166 event.created_at_u64(), 167 &event.author().to_hex(), 168 &event.tags_as_vec(), 169 event.content(), 170 ) 171 } 172 173 #[cfg(feature = "json")] 174 pub(crate) fn validate_trade_mutation_parts( 175 kind: u32, 176 authored_at: u64, 177 author: &str, 178 tags: &[Vec<String>], 179 content: &str, 180 ) -> Result<TradeMutationEnvelopeV1, RadrootsTradeMutationError> { 181 if !is_trade_mutation_event_kind(kind) { 182 return Err(RadrootsTradeMutationError::InvalidKind); 183 } 184 let envelope = trade_mutation_from_canonical_content(content) 185 .map_err(|_| RadrootsTradeMutationError::CanonicalContentMismatch)?; 186 if envelope.mutation_id.is_none() { 187 return Err(RadrootsTradeMutationError::CanonicalContentMismatch); 188 } 189 if envelope.mutation_kind().nostr_kind() != kind { 190 return Err(RadrootsTradeMutationError::InvalidKind); 191 } 192 if canonical_public_key(author)? != envelope.author_pubkey.to_hex() { 193 return Err(RadrootsTradeMutationError::AuthorMismatch); 194 } 195 if envelope.authored_at_unix_s != authored_at { 196 return Err(RadrootsTradeMutationError::AuthoredAtMismatch); 197 } 198 validate_trade_mutation_tags(&envelope, tags)?; 199 Ok(envelope) 200 } 201 202 #[cfg(feature = "json")] 203 /// Validates a trade mutation whose NIP-01 signature has already been verified. 204 pub fn trade_mutation_from_verified_event( 205 event: &RadrootsSignatureVerifiedEvent, 206 ) -> Result<TradeMutationEnvelopeV1, RadrootsTradeMutationError> { 207 trade_mutation_from_event(event.event()) 208 } 209 210 #[cfg(feature = "json")] 211 pub fn trade_mutation_tags( 212 envelope: &TradeMutationEnvelopeV1, 213 ) -> Result<Vec<Vec<String>>, RadrootsTradeMutationError> { 214 let canonical = canonical_trade_mutation_content(envelope.clone()) 215 .map_err(|_| RadrootsTradeMutationError::CanonicalContentMismatch)?; 216 canonical_trade_mutation_tags(&canonical.envelope) 217 } 218 219 #[cfg(feature = "json")] 220 pub fn validate_trade_mutation_tags( 221 envelope: &TradeMutationEnvelopeV1, 222 tags: &[Vec<String>], 223 ) -> Result<(), RadrootsTradeMutationError> { 224 if tags.len() > MAX_TRADE_MUTATION_TAGS { 225 return Err(RadrootsTradeMutationError::InvalidTagShape); 226 } 227 let proposal = envelope.mutation_kind() == TradeMutationKindV1::Proposal; 228 if tags 229 .iter() 230 .any(|tag| tag.first().map(String::as_str) == Some("e")) 231 { 232 return Err(RadrootsTradeMutationError::LegacyParentEventTag); 233 } 234 let trade_count = count_named(tags, "d"); 235 if trade_count > 1 { 236 return Err(RadrootsTradeMutationError::DuplicateTradeTag); 237 } 238 let mutation_count = count_marked(tags, "mutation"); 239 let root_count = count_marked(tags, "root"); 240 let parent_count = count_marked(tags, "parent"); 241 if count_named(tags, "x") != mutation_count + root_count + parent_count { 242 return Err(RadrootsTradeMutationError::InvalidTagShape); 243 } 244 if mutation_count == 0 { 245 return Err(RadrootsTradeMutationError::MissingMutationTag); 246 } 247 if mutation_count != 1 { 248 return Err(RadrootsTradeMutationError::InvalidTagShape); 249 } 250 if proposal { 251 if root_count != 0 { 252 return Err(RadrootsTradeMutationError::UnexpectedRootTag); 253 } 254 if parent_count != 0 { 255 return Err(RadrootsTradeMutationError::UnexpectedParentTag); 256 } 257 } else { 258 if root_count == 0 { 259 return Err(RadrootsTradeMutationError::MissingRootTag); 260 } 261 if root_count != 1 { 262 return Err(RadrootsTradeMutationError::InvalidTagShape); 263 } 264 if parent_count == 0 { 265 return Err(RadrootsTradeMutationError::MissingParentTag); 266 } 267 if parent_count > 4 { 268 return Err(RadrootsTradeMutationError::InvalidTagShape); 269 } 270 } 271 if trade_count == 0 || count_named(tags, "contract") != 1 || count_named(tags, "p") != 2 { 272 return Err(RadrootsTradeMutationError::InvalidTagShape); 273 } 274 if tags.iter().any(|tag| { 275 !matches!( 276 tag.first().map(String::as_str), 277 Some("contract" | "d" | "x" | "p") 278 ) 279 }) { 280 return Err(RadrootsTradeMutationError::UnexpectedTag); 281 } 282 283 let contract = exact_unmarked(tags.first(), "contract")?; 284 if contract != envelope.contract_id { 285 return Err(RadrootsTradeMutationError::ContractTagMismatch); 286 } 287 let trade = exact_unmarked(tags.get(1), "d")?; 288 if canonical_trade_id(trade)? != envelope.trade_id.to_hex() { 289 return Err(RadrootsTradeMutationError::TradeTagMismatch); 290 } 291 let mutation = exact_marked(tags.get(2), "mutation")?; 292 if canonical_mutation_id(mutation)? 293 != envelope 294 .mutation_id 295 .as_ref() 296 .ok_or(RadrootsTradeMutationError::CanonicalContentMismatch)? 297 .to_hex() 298 { 299 return Err(RadrootsTradeMutationError::MutationTagMismatch); 300 } 301 302 let mut cursor = 3; 303 if !proposal { 304 let root = exact_marked(tags.get(cursor), "root")?; 305 if canonical_mutation_id(root)? 306 != envelope 307 .root_mutation_id 308 .as_ref() 309 .ok_or(RadrootsTradeMutationError::MissingRootTag)? 310 .to_hex() 311 { 312 return Err(RadrootsTradeMutationError::RootTagMismatch); 313 } 314 cursor += 1; 315 } 316 let mut parsed_parents = Vec::with_capacity(parent_count); 317 for tag in tags.iter().skip(cursor).take(parent_count) { 318 let parent = canonical_mutation_id(exact_marked(Some(tag), "parent")?)?; 319 parsed_parents.push( 320 MutationId::parse(parent).map_err(|_| RadrootsTradeMutationError::InvalidIdentifier)?, 321 ); 322 } 323 if parsed_parents.windows(2).any(|pair| pair[0] >= pair[1]) { 324 return Err(RadrootsTradeMutationError::NoncanonicalParentOrder); 325 } 326 if parsed_parents != envelope.parent_mutation_ids { 327 return Err(RadrootsTradeMutationError::ParentTagMismatch); 328 } 329 cursor += parent_count; 330 let buyer = exact_unmarked(tags.get(cursor), "p")?; 331 let seller = exact_unmarked(tags.get(cursor + 1), "p")?; 332 if canonical_public_key(buyer)? != envelope.buyer_pubkey.to_hex() 333 || canonical_public_key(seller)? != envelope.seller_pubkey.to_hex() 334 { 335 return Err(RadrootsTradeMutationError::PartyTagOrderMismatch); 336 } 337 if cursor + 2 != tags.len() { 338 return Err(RadrootsTradeMutationError::InvalidTagShape); 339 } 340 validate_party_binding(envelope) 341 } 342 343 #[cfg(feature = "json")] 344 fn canonical_trade_mutation_tags( 345 envelope: &TradeMutationEnvelopeV1, 346 ) -> Result<Vec<Vec<String>>, RadrootsTradeMutationError> { 347 validate_party_binding(envelope)?; 348 let mutation = envelope 349 .mutation_id 350 .as_ref() 351 .ok_or(RadrootsTradeMutationError::CanonicalContentMismatch)?; 352 let mut tags = Vec::with_capacity(5 + envelope.parent_mutation_ids.len()); 353 tags.push(vec!["contract".to_string(), envelope.contract_id.clone()]); 354 tags.push(vec!["d".to_string(), envelope.trade_id.to_hex()]); 355 tags.push(vec![ 356 "x".to_string(), 357 mutation.to_hex(), 358 "mutation".to_string(), 359 ]); 360 if let Some(root) = &envelope.root_mutation_id { 361 tags.push(vec!["x".to_string(), root.to_hex(), "root".to_string()]); 362 } 363 for parent in &envelope.parent_mutation_ids { 364 tags.push(vec!["x".to_string(), parent.to_hex(), "parent".to_string()]); 365 } 366 tags.push(vec!["p".to_string(), envelope.buyer_pubkey.to_hex()]); 367 tags.push(vec!["p".to_string(), envelope.seller_pubkey.to_hex()]); 368 Ok(tags) 369 } 370 371 #[cfg(feature = "json")] 372 fn validate_party_binding( 373 envelope: &TradeMutationEnvelopeV1, 374 ) -> Result<(), RadrootsTradeMutationError> { 375 let expected_counterparty = if envelope.author_pubkey == envelope.buyer_pubkey { 376 &envelope.seller_pubkey 377 } else if envelope.author_pubkey == envelope.seller_pubkey { 378 &envelope.buyer_pubkey 379 } else { 380 return Err(RadrootsTradeMutationError::AuthorMismatch); 381 }; 382 if &envelope.counterparty_pubkey != expected_counterparty { 383 return Err(RadrootsTradeMutationError::AuthorMismatch); 384 } 385 Ok(()) 386 } 387 388 #[cfg(feature = "json")] 389 fn count_named(tags: &[Vec<String>], name: &str) -> usize { 390 tags.iter() 391 .filter(|tag| tag.first().map(String::as_str) == Some(name)) 392 .count() 393 } 394 395 #[cfg(feature = "json")] 396 fn count_marked(tags: &[Vec<String>], marker: &str) -> usize { 397 tags.iter() 398 .filter(|tag| { 399 tag.first().map(String::as_str) == Some("x") 400 && tag.get(2).map(String::as_str) == Some(marker) 401 }) 402 .count() 403 } 404 405 #[cfg(feature = "json")] 406 fn exact_unmarked<'a>( 407 tag: Option<&'a Vec<String>>, 408 name: &str, 409 ) -> Result<&'a str, RadrootsTradeMutationError> { 410 let tag = tag.ok_or(RadrootsTradeMutationError::InvalidTagShape)?; 411 if tag.len() != 2 || tag.first().map(String::as_str) != Some(name) { 412 return Err(RadrootsTradeMutationError::InvalidTagShape); 413 } 414 Ok(&tag[1]) 415 } 416 417 #[cfg(feature = "json")] 418 fn exact_marked<'a>( 419 tag: Option<&'a Vec<String>>, 420 marker: &str, 421 ) -> Result<&'a str, RadrootsTradeMutationError> { 422 let tag = tag.ok_or(RadrootsTradeMutationError::InvalidTagShape)?; 423 if tag.len() != 3 424 || tag.first().map(String::as_str) != Some("x") 425 || tag.get(2).map(String::as_str) != Some(marker) 426 { 427 return Err(RadrootsTradeMutationError::InvalidTagShape); 428 } 429 Ok(&tag[1]) 430 } 431 432 #[cfg(feature = "json")] 433 fn canonical_trade_id(value: &str) -> Result<String, RadrootsTradeMutationError> { 434 let parsed = 435 TradeId::parse(value).map_err(|_| RadrootsTradeMutationError::InvalidIdentifier)?; 436 let canonical = parsed.to_hex(); 437 if canonical != value { 438 return Err(RadrootsTradeMutationError::InvalidIdentifier); 439 } 440 Ok(canonical) 441 } 442 443 #[cfg(feature = "json")] 444 fn canonical_mutation_id(value: &str) -> Result<String, RadrootsTradeMutationError> { 445 let parsed = 446 MutationId::parse(value).map_err(|_| RadrootsTradeMutationError::InvalidIdentifier)?; 447 let canonical = parsed.to_hex(); 448 if canonical != value { 449 return Err(RadrootsTradeMutationError::InvalidIdentifier); 450 } 451 Ok(canonical) 452 } 453 454 #[cfg(feature = "json")] 455 fn canonical_public_key(value: &str) -> Result<String, RadrootsTradeMutationError> { 456 let parsed = 457 PublicKey::from_hex(value).map_err(|_| RadrootsTradeMutationError::InvalidIdentifier)?; 458 let canonical = parsed.to_hex(); 459 if canonical != value { 460 return Err(RadrootsTradeMutationError::InvalidIdentifier); 461 } 462 Ok(canonical) 463 } 464 465 #[cfg(all(test, feature = "json"))] 466 mod tests { 467 use super::*; 468 use radroots_event::{ 469 envelope::EventEnvelope, 470 envelope::EventEnvelopeParts, 471 id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId, TradeId}, 472 trade::{ 473 FulfillmentProfileV1, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, 474 RADROOTS_TRADE_SCHEMA_VERSION, TradeCancellationProfileV1, TradeCandidateLineV1, 475 TradeCandidateTermsV1, TradeEconomicAdjustmentV1, TradeEconomicsProfileV1, 476 TradeMutationBodyV1, TradeMutationEnvelopeV1, 477 }, 478 }; 479 use radroots_identity::PublicKey; 480 481 fn hex_64(character: char) -> String { 482 core::iter::repeat_n(character, 64).collect() 483 } 484 485 fn hex_32(character: char) -> String { 486 core::iter::repeat_n(character, 32).collect() 487 } 488 489 fn pubkey(character: char) -> PublicKey { 490 PublicKey::from_hex(&crate::test_fixtures::fixture_public_key_hex(character)).unwrap() 491 } 492 493 fn event_id(character: char) -> EventId { 494 EventId::parse(hex_64(character)).unwrap() 495 } 496 497 fn proposal() -> TradeMutationEnvelopeV1 { 498 TradeMutationEnvelopeV1 { 499 mutation_id: None, 500 contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string(), 501 schema_version: RADROOTS_TRADE_SCHEMA_VERSION, 502 trade_id: TradeId::parse(hex_32('1')).unwrap(), 503 root_mutation_id: None, 504 buyer_pubkey: pubkey('a'), 505 seller_pubkey: pubkey('b'), 506 farm_id: DTag::parse("farm-1").unwrap(), 507 parent_mutation_ids: Vec::new(), 508 author_pubkey: pubkey('a'), 509 counterparty_pubkey: pubkey('b'), 510 authored_at_unix_s: 1_799_000_000, 511 body: TradeMutationBodyV1::Proposal { 512 candidate: candidate(), 513 }, 514 } 515 } 516 517 fn candidate() -> TradeCandidateTermsV1 { 518 TradeCandidateTermsV1 { 519 candidate_id: None, 520 schema_version: RADROOTS_TRADE_SCHEMA_VERSION, 521 base_candidate_id: None, 522 supersession_intent: None, 523 buyer_pubkey: pubkey('a'), 524 seller_pubkey: pubkey('b'), 525 farm_id: DTag::parse("farm-1").unwrap(), 526 lines: vec![TradeCandidateLineV1 { 527 line_id: DTag::parse("line-1").unwrap(), 528 listing_addr: ClassifiedListingAddress::parse(format!( 529 "30402:{}:listing-1", 530 pubkey('b').to_hex() 531 )) 532 .unwrap(), 533 listing_event_id: event_id('c'), 534 listing_snapshot_sha256: hex_64('d'), 535 product_id: "carrots".to_string(), 536 option_id: None, 537 bin_id: InventoryBinId::parse("bin-1").unwrap(), 538 quantity_mantissa: "2".to_string(), 539 quantity_scale: 0, 540 unit_code: "count".to_string(), 541 unit_profile: "mvp-count".to_string(), 542 unit_price_mantissa: "500".to_string(), 543 currency_code: "USD".to_string(), 544 line_subtotal_mantissa: "1000".to_string(), 545 replaces_line_id: None, 546 }], 547 line_tombstones: Vec::new(), 548 economics: TradeEconomicsProfileV1 { 549 profile_id: "mvp-fixed".to_string(), 550 currency_code: "USD".to_string(), 551 currency_exponent: 2, 552 rounding_profile: "half-even".to_string(), 553 subtotal_mantissa: "1000".to_string(), 554 discount_total_mantissa: "0".to_string(), 555 adjustment_total_mantissa: "0".to_string(), 556 total_mantissa: "1000".to_string(), 557 adjustments: Vec::<TradeEconomicAdjustmentV1>::new(), 558 }, 559 fulfillment: FulfillmentProfileV1 { 560 profile_id: "market-pickup".to_string(), 561 method: "pickup".to_string(), 562 starts_at_unix_s: 1_800_000_000, 563 ends_at_unix_s: 1_800_003_600, 564 timezone: "America/New_York".to_string(), 565 utc_offset_seconds: -18_000, 566 fold: 0, 567 location_class: "farmstand".to_string(), 568 requires_private_terms: false, 569 }, 570 cancellation: TradeCancellationProfileV1 { 571 profile_id: "buyer-pre-agreement".to_string(), 572 buyer_pre_agreement: true, 573 post_agreement_cutoff_unix_s: None, 574 }, 575 private_terms: None, 576 proposal_expires_at_unix_s: 1_799_999_000, 577 } 578 } 579 580 #[test] 581 fn trade_mutation_event_build_roundtrips_canonical_content_and_tags() { 582 let parts = trade_mutation_event_build(proposal()).unwrap(); 583 assert_eq!( 584 parts.kind, 585 radroots_event::envelope::kind::KIND_TRADE_PROPOSAL 586 ); 587 assert_eq!( 588 parts.tags[0], 589 vec![ 590 "contract".to_string(), 591 RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string() 592 ] 593 ); 594 let envelope = trade_mutation_from_event( 595 &EventEnvelope::new(EventEnvelopeParts { 596 id: hex_64('e'), 597 author: pubkey('a').to_hex(), 598 created_at: 1_799_000_000, 599 kind: parts.kind, 600 tags: parts.tags, 601 content: parts.content, 602 sig: core::iter::repeat_n('f', 128).collect(), 603 }) 604 .unwrap(), 605 ) 606 .unwrap(); 607 assert_eq!(envelope.contract_id, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID); 608 } 609 610 #[test] 611 #[cfg_attr(coverage_nightly, coverage(off))] 612 fn trade_mutation_codec_rejects_all_invalid_wire_shapes() { 613 let canonical = canonical_trade_mutation_content(proposal()) 614 .unwrap() 615 .envelope; 616 let mut tags = trade_mutation_tags(&canonical).unwrap(); 617 *tags 618 .iter_mut() 619 .find(|tag| tag.first().map(String::as_str) == Some("contract")) 620 .unwrap() = vec!["contract".into(), "wrong-contract".into()]; 621 assert_eq!( 622 validate_trade_mutation_tags(&canonical, &tags).unwrap_err(), 623 RadrootsTradeMutationError::ContractTagMismatch 624 ); 625 626 let mut tags = trade_mutation_tags(&canonical).unwrap(); 627 *tags 628 .iter_mut() 629 .find(|tag| tag.first().map(String::as_str) == Some("d")) 630 .unwrap() = vec!["d".into(), hex_32('9')]; 631 assert_eq!( 632 validate_trade_mutation_tags(&canonical, &tags).unwrap_err(), 633 RadrootsTradeMutationError::TradeTagMismatch 634 ); 635 636 let mut tags = trade_mutation_tags(&canonical).unwrap(); 637 let party = tags.len() - 2; 638 tags.swap(party, party + 1); 639 assert_eq!( 640 validate_trade_mutation_tags(&canonical, &tags).unwrap_err(), 641 RadrootsTradeMutationError::PartyTagOrderMismatch 642 ); 643 644 let mut legacy = trade_mutation_tags(&canonical).unwrap(); 645 legacy.push(vec!["e".into(), hex_64('9')]); 646 assert_eq!( 647 validate_trade_mutation_tags(&canonical, &legacy).unwrap_err(), 648 RadrootsTradeMutationError::LegacyParentEventTag 649 ); 650 651 let mut missing_mutation = trade_mutation_tags(&canonical).unwrap(); 652 missing_mutation.remove(2); 653 assert_eq!( 654 validate_trade_mutation_tags(&canonical, &missing_mutation).unwrap_err(), 655 RadrootsTradeMutationError::MissingMutationTag 656 ); 657 658 let mut duplicate_trade = trade_mutation_tags(&canonical).unwrap(); 659 duplicate_trade.push(vec!["d".into(), hex_32('9')]); 660 assert_eq!( 661 validate_trade_mutation_tags(&canonical, &duplicate_trade).unwrap_err(), 662 RadrootsTradeMutationError::DuplicateTradeTag 663 ); 664 665 assert_eq!( 666 trade_mutation_event_build_with_extra_tags( 667 proposal(), 668 &[vec!["d".into(), hex_32('9')]], 669 ) 670 .unwrap_err(), 671 RadrootsTradeMutationError::CallerStructuralTagForbidden 672 ); 673 } 674 675 #[test] 676 fn trade_event_parser_binds_kind_author_time_content_and_markers() { 677 let built = trade_mutation_event_build(proposal()).expect("trade event"); 678 let event = |author: String, created_at, kind, tags: Vec<Vec<String>>, content: String| { 679 EventEnvelope::new(EventEnvelopeParts { 680 id: hex_64('e'), 681 author, 682 created_at, 683 kind, 684 tags, 685 content, 686 sig: core::iter::repeat_n('f', 128).collect(), 687 }) 688 .expect("structural envelope") 689 }; 690 assert_eq!( 691 trade_mutation_from_event(&event( 692 pubkey('a').to_hex(), 693 1_799_000_000, 694 1, 695 built.tags.clone(), 696 built.content.clone(), 697 )) 698 .unwrap_err(), 699 RadrootsTradeMutationError::InvalidKind 700 ); 701 assert_eq!( 702 trade_mutation_from_event(&event( 703 pubkey('c').to_hex(), 704 1_799_000_000, 705 built.kind, 706 built.tags.clone(), 707 built.content.clone(), 708 )) 709 .unwrap_err(), 710 RadrootsTradeMutationError::AuthorMismatch 711 ); 712 assert_eq!( 713 trade_mutation_from_event(&event( 714 pubkey('a').to_hex(), 715 1_799_000_001, 716 built.kind, 717 built.tags.clone(), 718 built.content.clone(), 719 )) 720 .unwrap_err(), 721 RadrootsTradeMutationError::AuthoredAtMismatch 722 ); 723 assert_eq!( 724 trade_mutation_from_event(&event( 725 pubkey('a').to_hex(), 726 1_799_000_000, 727 built.kind, 728 built.tags.clone(), 729 "{}".to_owned(), 730 )) 731 .unwrap_err(), 732 RadrootsTradeMutationError::CanonicalContentMismatch 733 ); 734 let mut unknown_marker = built.tags.clone(); 735 unknown_marker[2][2] = "unknown".to_owned(); 736 assert_eq!( 737 trade_mutation_from_event(&event( 738 pubkey('a').to_hex(), 739 1_799_000_000, 740 built.kind, 741 unknown_marker, 742 built.content, 743 )) 744 .unwrap_err(), 745 RadrootsTradeMutationError::InvalidTagShape 746 ); 747 } 748 749 #[test] 750 #[cfg_attr(coverage_nightly, coverage(off))] 751 fn trade_errors_have_fixed_redacted_diagnostics() { 752 let errors = [ 753 RadrootsTradeMutationError::CallerStructuralTagForbidden, 754 RadrootsTradeMutationError::DuplicateTradeTag, 755 RadrootsTradeMutationError::LegacyParentEventTag, 756 RadrootsTradeMutationError::MissingParentTag, 757 RadrootsTradeMutationError::MissingMutationTag, 758 RadrootsTradeMutationError::MissingRootTag, 759 RadrootsTradeMutationError::NoncanonicalParentOrder, 760 RadrootsTradeMutationError::PartyTagOrderMismatch, 761 RadrootsTradeMutationError::UnexpectedParentTag, 762 RadrootsTradeMutationError::UnexpectedRootTag, 763 RadrootsTradeMutationError::InvalidKind, 764 RadrootsTradeMutationError::AuthorMismatch, 765 RadrootsTradeMutationError::AuthoredAtMismatch, 766 RadrootsTradeMutationError::CanonicalContentMismatch, 767 RadrootsTradeMutationError::InvalidIdentifier, 768 RadrootsTradeMutationError::InvalidTagShape, 769 RadrootsTradeMutationError::UnexpectedTag, 770 RadrootsTradeMutationError::ContractTagMismatch, 771 RadrootsTradeMutationError::TradeTagMismatch, 772 RadrootsTradeMutationError::MutationTagMismatch, 773 RadrootsTradeMutationError::RootTagMismatch, 774 RadrootsTradeMutationError::ParentTagMismatch, 775 ]; 776 for error in errors { 777 let display = error.to_string(); 778 let debug = format!("{error:?}"); 779 assert!(!display.is_empty()); 780 assert!(!debug.contains(&hex_64('a'))); 781 assert!(std::error::Error::source(&error).is_none()); 782 } 783 } 784 }