trade.rs (65594B)
1 #![forbid(unsafe_code)] 2 3 #[path = "order.rs"] 4 pub mod order; 5 #[path = "order_economics.rs"] 6 pub mod order_economics; 7 #[path = "trade_validation.rs"] 8 pub mod validation; 9 10 #[cfg(all(not(feature = "std"), feature = "serde"))] 11 use alloc::{collections::BTreeMap, format, string::ToString}; 12 #[cfg(not(feature = "std"))] 13 use alloc::{string::String, vec::Vec}; 14 #[cfg(all(feature = "std", feature = "serde"))] 15 use std::{collections::BTreeMap, string::ToString}; 16 #[cfg(feature = "std")] 17 use std::{string::String, vec::Vec}; 18 19 use core::fmt; 20 21 use crate::envelope::kind::{ 22 KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, 23 KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, 24 }; 25 pub use crate::id::{CandidateId, MutationId, TradeId}; 26 use crate::id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId, ParseError}; 27 use radroots_identity::PublicKey; 28 #[cfg(feature = "serde")] 29 use serde::{ 30 Deserialize, Deserializer, Serialize, 31 de::{Error as _, MapAccess, SeqAccess, Visitor}, 32 }; 33 #[cfg(feature = "serde")] 34 use serde_json::{Map, Number, Value}; 35 #[cfg(feature = "serde")] 36 use sha2::{Digest, Sha256}; 37 38 pub const RADROOTS_TRADE_SCHEMA_VERSION: u16 = 1; 39 pub const RADROOTS_TRADE_MUTATION_DOMAIN: &[u8] = b"radroots:trade-mutation:v1\0"; 40 pub const RADROOTS_TRADE_CANDIDATE_DOMAIN: &[u8] = b"radroots:trade-candidate:v1\0"; 41 pub const RADROOTS_TRADE_PROPOSAL_CONTRACT_ID: &str = "radroots.trade.proposal.v1"; 42 pub const RADROOTS_TRADE_DECISION_CONTRACT_ID: &str = "radroots.trade.decision.v1"; 43 pub const RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID: &str = 44 "radroots.trade.revision_proposal.v1"; 45 pub const RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID: &str = 46 "radroots.trade.revision_decision.v1"; 47 pub const RADROOTS_TRADE_CANCELLATION_CONTRACT_ID: &str = "radroots.trade.cancellation.v1"; 48 pub const RADROOTS_TRADE_SELLER_RESERVATION_ASSERTION_CONTRACT_ID: &str = 49 "radroots.trade.seller_reservation_assertion.v1"; 50 pub const RADROOTS_TRADE_MUTATION_CONTRACT_IDS: [&str; 5] = [ 51 RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, 52 RADROOTS_TRADE_DECISION_CONTRACT_ID, 53 RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, 54 RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, 55 RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, 56 ]; 57 pub const RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES: usize = 64 * 1024; 58 pub const RADROOTS_TRADE_MAX_ACTIVE_LINES: usize = 64; 59 pub const RADROOTS_TRADE_MAX_ADJUSTMENTS: usize = 128; 60 pub const RADROOTS_TRADE_MAX_PARENT_MUTATIONS: usize = 4; 61 pub const RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES: usize = 64 * 1024; 62 63 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 64 pub enum TradeMutationKindV1 { 65 Proposal, 66 Decision, 67 RevisionProposal, 68 RevisionDecision, 69 Cancellation, 70 } 71 72 impl TradeMutationKindV1 { 73 pub const fn contract_id(self) -> &'static str { 74 match self { 75 Self::Proposal => RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, 76 Self::Decision => RADROOTS_TRADE_DECISION_CONTRACT_ID, 77 Self::RevisionProposal => RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, 78 Self::RevisionDecision => RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, 79 Self::Cancellation => RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, 80 } 81 } 82 83 pub const fn nostr_kind(self) -> u32 { 84 match self { 85 Self::Proposal => KIND_TRADE_PROPOSAL, 86 Self::Decision => KIND_TRADE_DECISION, 87 Self::RevisionProposal => KIND_TRADE_REVISION_PROPOSAL, 88 Self::RevisionDecision => KIND_TRADE_REVISION_DECISION, 89 Self::Cancellation => KIND_TRADE_CANCELLATION, 90 } 91 } 92 } 93 94 #[derive(Clone, Debug, PartialEq, Eq)] 95 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 96 pub struct TradeMutationEnvelopeV1 { 97 pub mutation_id: Option<MutationId>, 98 pub contract_id: String, 99 pub schema_version: u16, 100 pub trade_id: TradeId, 101 pub root_mutation_id: Option<MutationId>, 102 pub buyer_pubkey: PublicKey, 103 pub seller_pubkey: PublicKey, 104 pub farm_id: DTag, 105 pub parent_mutation_ids: Vec<MutationId>, 106 pub author_pubkey: PublicKey, 107 pub counterparty_pubkey: PublicKey, 108 pub authored_at_unix_s: u64, 109 pub body: TradeMutationBodyV1, 110 } 111 112 impl TradeMutationEnvelopeV1 { 113 pub fn mutation_kind(&self) -> TradeMutationKindV1 { 114 self.body.mutation_kind() 115 } 116 117 pub fn validate(&self) -> Result<(), TradeProtocolError> { 118 if self.schema_version != RADROOTS_TRADE_SCHEMA_VERSION { 119 return Err(TradeProtocolError::InvalidSchemaVersion { 120 expected: RADROOTS_TRADE_SCHEMA_VERSION, 121 actual: self.schema_version, 122 }); 123 } 124 let expected = self.mutation_kind().contract_id(); 125 if self.contract_id != expected { 126 return Err(TradeProtocolError::ContractMismatch { 127 expected, 128 actual: self.contract_id.clone(), 129 }); 130 } 131 validate_parent_mutation_ids(self.mutation_id.as_ref(), &self.parent_mutation_ids)?; 132 match self.mutation_kind() { 133 TradeMutationKindV1::Proposal => { 134 if self.root_mutation_id.is_some() || !self.parent_mutation_ids.is_empty() { 135 return Err(TradeProtocolError::InvalidInitialParents); 136 } 137 } 138 _ => { 139 if self.root_mutation_id.is_none() || self.parent_mutation_ids.is_empty() { 140 return Err(TradeProtocolError::MissingParentMutation); 141 } 142 } 143 } 144 self.body.validate() 145 } 146 } 147 148 #[derive(Clone, Debug, PartialEq, Eq)] 149 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 150 #[cfg_attr( 151 feature = "serde", 152 serde(rename_all = "snake_case", tag = "mutation_type") 153 )] 154 pub enum TradeMutationBodyV1 { 155 Proposal { 156 candidate: TradeCandidateTermsV1, 157 }, 158 Decision { 159 proposal_mutation_id: MutationId, 160 candidate_id: CandidateId, 161 decision: TradeDecisionV1, 162 }, 163 RevisionProposal { 164 candidate: TradeCandidateTermsV1, 165 }, 166 RevisionDecision { 167 proposal_mutation_id: MutationId, 168 candidate_id: CandidateId, 169 decision: TradeDecisionV1, 170 }, 171 Cancellation { 172 target_candidate_id: Option<CandidateId>, 173 target_claim_mutation_id: Option<MutationId>, 174 reason: String, 175 }, 176 } 177 178 impl TradeMutationBodyV1 { 179 pub const fn mutation_kind(&self) -> TradeMutationKindV1 { 180 match self { 181 Self::Proposal { .. } => TradeMutationKindV1::Proposal, 182 Self::Decision { .. } => TradeMutationKindV1::Decision, 183 Self::RevisionProposal { .. } => TradeMutationKindV1::RevisionProposal, 184 Self::RevisionDecision { .. } => TradeMutationKindV1::RevisionDecision, 185 Self::Cancellation { .. } => TradeMutationKindV1::Cancellation, 186 } 187 } 188 189 pub fn validate(&self) -> Result<(), TradeProtocolError> { 190 match self { 191 Self::Proposal { candidate } | Self::RevisionProposal { candidate } => { 192 candidate.validate() 193 } 194 Self::Decision { 195 proposal_mutation_id: _, 196 candidate_id: _, 197 decision, 198 } 199 | Self::RevisionDecision { 200 proposal_mutation_id: _, 201 candidate_id: _, 202 decision, 203 } => decision.validate(), 204 Self::Cancellation { 205 target_candidate_id, 206 target_claim_mutation_id, 207 reason, 208 } => { 209 if target_candidate_id.is_none() && target_claim_mutation_id.is_none() { 210 return Err(TradeProtocolError::MissingCancellationTarget); 211 } 212 validate_non_empty(reason, "reason") 213 } 214 } 215 } 216 } 217 218 #[derive(Clone, Debug, PartialEq, Eq)] 219 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 220 pub struct TradeCandidateTermsV1 { 221 pub candidate_id: Option<CandidateId>, 222 pub schema_version: u16, 223 pub base_candidate_id: Option<CandidateId>, 224 pub supersession_intent: Option<String>, 225 pub buyer_pubkey: PublicKey, 226 pub seller_pubkey: PublicKey, 227 pub farm_id: DTag, 228 pub lines: Vec<TradeCandidateLineV1>, 229 pub line_tombstones: Vec<TradeLineTombstoneV1>, 230 pub economics: TradeEconomicsProfileV1, 231 pub fulfillment: FulfillmentProfileV1, 232 pub cancellation: TradeCancellationProfileV1, 233 pub private_terms: Option<TradePrivateTermsRefV1>, 234 pub proposal_expires_at_unix_s: u64, 235 } 236 237 impl TradeCandidateTermsV1 { 238 pub fn validate(&self) -> Result<(), TradeProtocolError> { 239 if self.schema_version != RADROOTS_TRADE_SCHEMA_VERSION { 240 return Err(TradeProtocolError::InvalidSchemaVersion { 241 expected: RADROOTS_TRADE_SCHEMA_VERSION, 242 actual: self.schema_version, 243 }); 244 } 245 if self.lines.is_empty() { 246 return Err(TradeProtocolError::MissingLines); 247 } 248 if self.lines.len() > RADROOTS_TRADE_MAX_ACTIVE_LINES { 249 return Err(TradeProtocolError::TooManyLines { 250 max: RADROOTS_TRADE_MAX_ACTIVE_LINES, 251 actual: self.lines.len(), 252 }); 253 } 254 validate_sorted_unique_by(&self.lines, |line| line.line_id.as_str(), "lines")?; 255 validate_sorted_unique_by( 256 &self.line_tombstones, 257 |line| line.line_id.as_str(), 258 "line_tombstones", 259 )?; 260 for line in &self.lines { 261 line.validate()?; 262 } 263 for tombstone in &self.line_tombstones { 264 tombstone.validate()?; 265 } 266 self.economics.validate()?; 267 self.fulfillment.validate()?; 268 self.cancellation.validate()?; 269 if let Some(private_terms) = &self.private_terms { 270 private_terms.validate()?; 271 } 272 Ok(()) 273 } 274 } 275 276 #[derive(Clone, Debug, PartialEq, Eq)] 277 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 278 pub struct TradeCandidateLineV1 { 279 pub line_id: DTag, 280 pub listing_addr: ClassifiedListingAddress, 281 pub listing_event_id: EventId, 282 pub listing_snapshot_sha256: String, 283 pub product_id: String, 284 pub option_id: Option<String>, 285 pub bin_id: InventoryBinId, 286 pub quantity_mantissa: String, 287 pub quantity_scale: u8, 288 pub unit_code: String, 289 pub unit_profile: String, 290 pub unit_price_mantissa: String, 291 pub currency_code: String, 292 pub line_subtotal_mantissa: String, 293 pub replaces_line_id: Option<DTag>, 294 } 295 296 impl TradeCandidateLineV1 { 297 pub fn validate(&self) -> Result<(), TradeProtocolError> { 298 validate_sha256_hex(&self.listing_snapshot_sha256, "listing_snapshot_sha256")?; 299 validate_non_empty(&self.product_id, "product_id")?; 300 if let Some(option_id) = &self.option_id { 301 validate_non_empty(option_id, "option_id")?; 302 } 303 validate_decimal_string(&self.quantity_mantissa, "quantity_mantissa")?; 304 validate_non_empty(&self.unit_code, "unit_code")?; 305 validate_non_empty(&self.unit_profile, "unit_profile")?; 306 validate_decimal_string(&self.unit_price_mantissa, "unit_price_mantissa")?; 307 validate_non_empty(&self.currency_code, "currency_code")?; 308 validate_decimal_string(&self.line_subtotal_mantissa, "line_subtotal_mantissa") 309 } 310 } 311 312 #[derive(Clone, Debug, PartialEq, Eq)] 313 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 314 pub struct TradeLineTombstoneV1 { 315 pub line_id: DTag, 316 pub reason: String, 317 } 318 319 impl TradeLineTombstoneV1 { 320 pub fn validate(&self) -> Result<(), TradeProtocolError> { 321 validate_non_empty(&self.reason, "line_tombstone.reason") 322 } 323 } 324 325 #[derive(Clone, Debug, PartialEq, Eq)] 326 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 327 pub struct TradeEconomicsProfileV1 { 328 pub profile_id: String, 329 pub currency_code: String, 330 pub currency_exponent: u8, 331 pub rounding_profile: String, 332 pub subtotal_mantissa: String, 333 pub discount_total_mantissa: String, 334 pub adjustment_total_mantissa: String, 335 pub total_mantissa: String, 336 pub adjustments: Vec<TradeEconomicAdjustmentV1>, 337 } 338 339 impl TradeEconomicsProfileV1 { 340 pub fn validate(&self) -> Result<(), TradeProtocolError> { 341 validate_non_empty(&self.profile_id, "economics.profile_id")?; 342 validate_non_empty(&self.currency_code, "economics.currency_code")?; 343 validate_non_empty(&self.rounding_profile, "economics.rounding_profile")?; 344 validate_decimal_string(&self.subtotal_mantissa, "economics.subtotal_mantissa")?; 345 validate_decimal_string( 346 &self.discount_total_mantissa, 347 "economics.discount_total_mantissa", 348 )?; 349 validate_decimal_string( 350 &self.adjustment_total_mantissa, 351 "economics.adjustment_total_mantissa", 352 )?; 353 validate_decimal_string(&self.total_mantissa, "economics.total_mantissa")?; 354 if self.adjustments.len() > RADROOTS_TRADE_MAX_ADJUSTMENTS { 355 return Err(TradeProtocolError::TooManyAdjustments { 356 max: RADROOTS_TRADE_MAX_ADJUSTMENTS, 357 actual: self.adjustments.len(), 358 }); 359 } 360 validate_sorted_unique_by( 361 &self.adjustments, 362 |line| line.adjustment_id.as_str(), 363 "adjustments", 364 )?; 365 for adjustment in &self.adjustments { 366 adjustment.validate()?; 367 } 368 Ok(()) 369 } 370 } 371 372 #[derive(Clone, Debug, PartialEq, Eq)] 373 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 374 pub struct TradeEconomicAdjustmentV1 { 375 pub adjustment_id: DTag, 376 pub actor: String, 377 pub effect: String, 378 pub amount_mantissa: String, 379 pub reason: String, 380 } 381 382 impl TradeEconomicAdjustmentV1 { 383 pub fn validate(&self) -> Result<(), TradeProtocolError> { 384 validate_non_empty(&self.actor, "adjustment.actor")?; 385 validate_non_empty(&self.effect, "adjustment.effect")?; 386 validate_decimal_string(&self.amount_mantissa, "adjustment.amount_mantissa")?; 387 validate_non_empty(&self.reason, "adjustment.reason") 388 } 389 } 390 391 #[derive(Clone, Debug, PartialEq, Eq)] 392 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 393 pub struct FulfillmentProfileV1 { 394 pub profile_id: String, 395 pub method: String, 396 pub starts_at_unix_s: u64, 397 pub ends_at_unix_s: u64, 398 pub timezone: String, 399 pub utc_offset_seconds: i32, 400 pub fold: u8, 401 pub location_class: String, 402 pub requires_private_terms: bool, 403 } 404 405 impl FulfillmentProfileV1 { 406 pub fn validate(&self) -> Result<(), TradeProtocolError> { 407 validate_non_empty(&self.profile_id, "fulfillment.profile_id")?; 408 validate_non_empty(&self.method, "fulfillment.method")?; 409 validate_non_empty(&self.timezone, "fulfillment.timezone")?; 410 validate_non_empty(&self.location_class, "fulfillment.location_class")?; 411 if self.ends_at_unix_s <= self.starts_at_unix_s { 412 return Err(TradeProtocolError::InvalidTimeRange); 413 } 414 if self.fold > 1 { 415 return Err(TradeProtocolError::InvalidField("fulfillment.fold")); 416 } 417 Ok(()) 418 } 419 } 420 421 #[derive(Clone, Debug, PartialEq, Eq)] 422 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 423 pub struct TradeCancellationProfileV1 { 424 pub profile_id: String, 425 pub buyer_pre_agreement: bool, 426 pub post_agreement_cutoff_unix_s: Option<u64>, 427 } 428 429 impl TradeCancellationProfileV1 { 430 pub fn validate(&self) -> Result<(), TradeProtocolError> { 431 validate_non_empty(&self.profile_id, "cancellation.profile_id") 432 } 433 } 434 435 #[derive(Clone, Debug, PartialEq, Eq)] 436 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 437 pub struct TradePrivateTermsRefV1 { 438 pub artifact_id: String, 439 pub schema_id: String, 440 pub ciphertext_commitment: String, 441 pub required_acknowledgement: bool, 442 } 443 444 impl TradePrivateTermsRefV1 { 445 pub fn validate(&self) -> Result<(), TradeProtocolError> { 446 validate_non_empty(&self.artifact_id, "private_terms.artifact_id")?; 447 validate_non_empty(&self.schema_id, "private_terms.schema_id")?; 448 validate_sha256_hex( 449 &self.ciphertext_commitment, 450 "private_terms.ciphertext_commitment", 451 ) 452 } 453 } 454 455 #[derive(Clone, Debug, PartialEq, Eq)] 456 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 457 pub struct SellerReservationAssertionV1 { 458 pub reservation_id: DTag, 459 pub inventory_authority_id: PublicKey, 460 pub inventory_epoch: u64, 461 pub candidate_id: CandidateId, 462 pub commitments: Vec<SellerReservationLineV1>, 463 pub reservation_expires_at_unix_s: u64, 464 pub assertion_commitment: String, 465 } 466 467 impl SellerReservationAssertionV1 { 468 pub fn validate(&self) -> Result<(), TradeProtocolError> { 469 if self.commitments.is_empty() { 470 return Err(TradeProtocolError::MissingReservationCommitments); 471 } 472 validate_sorted_unique_by( 473 &self.commitments, 474 |line| line.line_id.as_str(), 475 "reservation.commitments", 476 )?; 477 for commitment in &self.commitments { 478 commitment.validate()?; 479 } 480 validate_sha256_hex( 481 &self.assertion_commitment, 482 "reservation.assertion_commitment", 483 ) 484 } 485 } 486 487 #[derive(Clone, Debug, PartialEq, Eq)] 488 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 489 pub struct SellerReservationLineV1 { 490 pub line_id: DTag, 491 pub bin_id: InventoryBinId, 492 pub quantity_mantissa: String, 493 pub quantity_scale: u8, 494 pub unit_code: String, 495 } 496 497 impl SellerReservationLineV1 { 498 pub fn validate(&self) -> Result<(), TradeProtocolError> { 499 validate_decimal_string( 500 &self.quantity_mantissa, 501 "reservation.line.quantity_mantissa", 502 )?; 503 validate_non_empty(&self.unit_code, "reservation.line.unit_code") 504 } 505 } 506 507 #[derive(Clone, Debug, PartialEq, Eq)] 508 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 509 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))] 510 pub enum TradeDecisionV1 { 511 Accepted { 512 reservation_assertion: Option<SellerReservationAssertionV1>, 513 }, 514 Declined { 515 reason: String, 516 }, 517 } 518 519 impl TradeDecisionV1 { 520 pub fn validate(&self) -> Result<(), TradeProtocolError> { 521 match self { 522 Self::Accepted { 523 reservation_assertion, 524 } => { 525 if let Some(assertion) = reservation_assertion { 526 assertion.validate()?; 527 } 528 Ok(()) 529 } 530 Self::Declined { reason } => validate_non_empty(reason, "decision.reason"), 531 } 532 } 533 } 534 535 #[derive(Clone, Debug, PartialEq, Eq)] 536 pub struct TradeCanonicalMutationV1 { 537 pub mutation_id: MutationId, 538 pub envelope: TradeMutationEnvelopeV1, 539 pub content: String, 540 } 541 542 #[derive(Clone, Debug, PartialEq, Eq)] 543 pub enum TradeProtocolError { 544 InvalidSchemaVersion { 545 expected: u16, 546 actual: u16, 547 }, 548 ContractMismatch { 549 expected: &'static str, 550 actual: String, 551 }, 552 InvalidInitialParents, 553 MissingParentMutation, 554 TooManyParents { 555 max: usize, 556 actual: usize, 557 }, 558 UnsortedParents, 559 DuplicateParent, 560 SelfParent, 561 MissingLines, 562 TooManyLines { 563 max: usize, 564 actual: usize, 565 }, 566 TooManyAdjustments { 567 max: usize, 568 actual: usize, 569 }, 570 DuplicateKey(String), 571 InvalidJson(String), 572 NonCanonicalJson, 573 UnsupportedNumber, 574 ContentTooLarge { 575 max: usize, 576 actual: usize, 577 }, 578 EmptyField(&'static str), 579 InvalidField(&'static str), 580 InvalidIdentifier { 581 field: &'static str, 582 error: ParseError, 583 }, 584 InvalidTimeRange, 585 MissingReservationCommitments, 586 MissingCancellationTarget, 587 CandidateIdMismatch { 588 declared: String, 589 computed: String, 590 }, 591 MutationIdMismatch { 592 declared: String, 593 computed: String, 594 }, 595 } 596 597 impl fmt::Display for TradeProtocolError { 598 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 599 match self { 600 Self::InvalidSchemaVersion { expected, actual } => { 601 write!(f, "trade schema version {actual} must be {expected}") 602 } 603 Self::ContractMismatch { expected, actual } => { 604 write!(f, "trade contract {actual} must be {expected}") 605 } 606 Self::InvalidInitialParents => { 607 write!(f, "initial trade proposal must not have parents") 608 } 609 Self::MissingParentMutation => write!(f, "trade mutation requires a parent mutation"), 610 Self::TooManyParents { max, actual } => { 611 write!(f, "trade mutation has {actual} parents; max is {max}") 612 } 613 Self::UnsortedParents => write!(f, "trade mutation parents must be sorted"), 614 Self::DuplicateParent => write!(f, "trade mutation parents must be unique"), 615 Self::SelfParent => write!(f, "trade mutation cannot reference itself as a parent"), 616 Self::MissingLines => write!(f, "trade candidate requires at least one active line"), 617 Self::TooManyLines { max, actual } => { 618 write!(f, "trade candidate has {actual} active lines; max is {max}") 619 } 620 Self::TooManyAdjustments { max, actual } => { 621 write!(f, "trade economics has {actual} adjustments; max is {max}") 622 } 623 Self::DuplicateKey(key) => write!(f, "trade canonical JSON has duplicate key {key}"), 624 Self::InvalidJson(error) => write!(f, "trade canonical JSON is invalid: {error}"), 625 Self::NonCanonicalJson => write!(f, "trade JSON is not canonical JCS"), 626 Self::UnsupportedNumber => write!(f, "trade canonical JSON number is unsupported"), 627 Self::ContentTooLarge { max, actual } => { 628 write!(f, "trade content is {actual} bytes; max is {max}") 629 } 630 Self::EmptyField(field) => write!(f, "trade field {field} cannot be empty"), 631 Self::InvalidField(field) => write!(f, "trade field {field} is invalid"), 632 Self::InvalidIdentifier { field, error } => { 633 write!(f, "trade field {field} is invalid: {error}") 634 } 635 Self::InvalidTimeRange => write!(f, "trade fulfillment time range is invalid"), 636 Self::MissingReservationCommitments => { 637 write!(f, "seller reservation assertion requires commitments") 638 } 639 Self::MissingCancellationTarget => write!(f, "trade cancellation requires a target"), 640 Self::CandidateIdMismatch { declared, computed } => write!( 641 f, 642 "trade candidate id mismatch: declared {declared}, computed {computed}" 643 ), 644 Self::MutationIdMismatch { declared, computed } => write!( 645 f, 646 "trade mutation id mismatch: declared {declared}, computed {computed}" 647 ), 648 } 649 } 650 } 651 652 #[cfg(feature = "std")] 653 impl std::error::Error for TradeProtocolError {} 654 655 #[cfg(feature = "serde")] 656 pub fn canonical_trade_candidate_id( 657 candidate: &TradeCandidateTermsV1, 658 ) -> Result<CandidateId, TradeProtocolError> { 659 let mut value = serialize_trade_value(candidate); 660 remove_object_field(&mut value, "candidate_id")?; 661 let canonical = canonical_jcs_value(&value)?; 662 Ok(trade_candidate_id_from_canonical(canonical.as_bytes())) 663 } 664 665 #[cfg(feature = "serde")] 666 pub fn canonical_trade_mutation_id( 667 envelope: &TradeMutationEnvelopeV1, 668 ) -> Result<MutationId, TradeProtocolError> { 669 let mut value = serialize_trade_value(envelope); 670 remove_object_field(&mut value, "mutation_id")?; 671 let canonical = canonical_jcs_value(&value)?; 672 Ok(trade_mutation_id_from_canonical(canonical.as_bytes())) 673 } 674 675 #[cfg(feature = "serde")] 676 pub fn canonical_trade_mutation_content( 677 mut envelope: TradeMutationEnvelopeV1, 678 ) -> Result<TradeCanonicalMutationV1, TradeProtocolError> { 679 finalize_body_candidate_ids(&mut envelope.body)?; 680 envelope.validate()?; 681 let mutation_id = canonical_trade_mutation_id(&envelope)?; 682 envelope.mutation_id = Some(mutation_id); 683 let value = serialize_trade_value(&envelope); 684 let content = canonical_jcs_value(&value)?; 685 if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES { 686 return Err(TradeProtocolError::ContentTooLarge { 687 max: RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES, 688 actual: content.len(), 689 }); 690 } 691 Ok(TradeCanonicalMutationV1 { 692 mutation_id, 693 envelope, 694 content, 695 }) 696 } 697 698 #[cfg(feature = "serde")] 699 pub fn trade_mutation_from_canonical_content( 700 content: &str, 701 ) -> Result<TradeMutationEnvelopeV1, TradeProtocolError> { 702 if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES { 703 return Err(TradeProtocolError::ContentTooLarge { 704 max: RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES, 705 actual: content.len(), 706 }); 707 } 708 let value = parse_json_without_duplicate_keys(content)?; 709 if canonical_jcs_value(&value)? != content { 710 return Err(TradeProtocolError::NonCanonicalJson); 711 } 712 let envelope: TradeMutationEnvelopeV1 = serde_json::from_value(value) 713 .map_err(|error| TradeProtocolError::InvalidJson(error.to_string()))?; 714 envelope.validate()?; 715 verify_candidate_ids(&envelope.body)?; 716 if let Some(declared) = &envelope.mutation_id { 717 let computed = canonical_trade_mutation_id(&envelope)?; 718 if declared != &computed { 719 return Err(TradeProtocolError::MutationIdMismatch { 720 declared: declared.to_string(), 721 computed: computed.to_string(), 722 }); 723 } 724 } 725 Ok(envelope) 726 } 727 728 #[cfg(feature = "serde")] 729 pub fn canonical_jcs_from_str(content: &str) -> Result<String, TradeProtocolError> { 730 let value = parse_json_without_duplicate_keys(content)?; 731 canonical_jcs_value(&value) 732 } 733 734 #[cfg(feature = "serde")] 735 pub fn canonical_jcs_value(value: &Value) -> Result<String, TradeProtocolError> { 736 let mut output = String::new(); 737 write_canonical_jcs(value, &mut output)?; 738 Ok(output) 739 } 740 741 #[cfg(feature = "serde")] 742 fn parse_json_without_duplicate_keys(content: &str) -> Result<Value, TradeProtocolError> { 743 let mut deserializer = serde_json::Deserializer::from_str(content); 744 let value = NoDuplicateJsonValue::deserialize(&mut deserializer).map_err(map_json_error)?; 745 deserializer.end().map_err(map_json_error)?; 746 Ok(value.0) 747 } 748 749 #[cfg(feature = "serde")] 750 fn map_json_error(error: serde_json::Error) -> TradeProtocolError { 751 let message = error.to_string(); 752 if let Some(key) = message.strip_prefix("duplicate key: ") { 753 let key = key.split(" at line ").next().unwrap_or(key).to_string(); 754 return TradeProtocolError::DuplicateKey(key); 755 } 756 TradeProtocolError::InvalidJson(message) 757 } 758 759 #[cfg(feature = "serde")] 760 fn write_canonical_jcs(value: &Value, output: &mut String) -> Result<(), TradeProtocolError> { 761 match value { 762 Value::Null => output.push_str("null"), 763 Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }), 764 Value::Number(number) => output.push_str(&canonical_number(number)?), 765 Value::String(value) => { 766 output.push_str(canonical_json_string(value).as_str()); 767 } 768 Value::Array(values) => { 769 output.push('['); 770 for (index, item) in values.iter().enumerate() { 771 if index > 0 { 772 output.push(','); 773 } 774 write_canonical_jcs(item, output)?; 775 } 776 output.push(']'); 777 } 778 Value::Object(map) => { 779 output.push('{'); 780 let mut keys = map.keys().collect::<Vec<_>>(); 781 keys.sort(); 782 for (index, key) in keys.iter().enumerate() { 783 if index > 0 { 784 output.push(','); 785 } 786 output.push_str(canonical_json_string(key.as_str()).as_str()); 787 output.push(':'); 788 let value = &map[key.as_str()]; 789 write_canonical_jcs(value, output)?; 790 } 791 output.push('}'); 792 } 793 } 794 Ok(()) 795 } 796 797 #[cfg(feature = "serde")] 798 fn canonical_number(number: &Number) -> Result<String, TradeProtocolError> { 799 if number.is_i64() || number.is_u64() { 800 Ok(number.to_string()) 801 } else { 802 Err(TradeProtocolError::UnsupportedNumber) 803 } 804 } 805 806 #[cfg(feature = "serde")] 807 fn digest_prefixed(domain: &[u8], bytes: &[u8]) -> String { 808 let mut hasher = Sha256::new(); 809 hasher.update(domain); 810 hasher.update(bytes); 811 hex::encode(hasher.finalize()) 812 } 813 814 #[cfg(feature = "serde")] 815 #[cfg_attr(coverage_nightly, coverage(off))] 816 fn serialize_trade_value(value: &impl Serialize) -> Value { 817 serde_json::to_value(value).expect("closed trade models always serialize to JSON values") 818 } 819 820 #[cfg(feature = "serde")] 821 #[cfg_attr(coverage_nightly, coverage(off))] 822 fn canonical_json_string(value: &str) -> String { 823 serde_json::to_string(value).expect("JSON strings always serialize") 824 } 825 826 #[cfg(feature = "serde")] 827 #[cfg_attr(coverage_nightly, coverage(off))] 828 fn trade_candidate_id_from_canonical(canonical: &[u8]) -> CandidateId { 829 CandidateId::parse(digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical)) 830 .expect("SHA-256 always produces a canonical 64-character identifier") 831 } 832 833 #[cfg(feature = "serde")] 834 #[cfg_attr(coverage_nightly, coverage(off))] 835 fn trade_mutation_id_from_canonical(canonical: &[u8]) -> MutationId { 836 MutationId::parse(digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical)) 837 .expect("SHA-256 always produces a canonical 64-character identifier") 838 } 839 840 #[cfg(feature = "serde")] 841 fn remove_object_field(value: &mut Value, field: &'static str) -> Result<(), TradeProtocolError> { 842 let Value::Object(map) = value else { 843 return Err(TradeProtocolError::InvalidJson( 844 "root must be object".to_string(), 845 )); 846 }; 847 map.remove(field); 848 Ok(()) 849 } 850 851 #[cfg(feature = "serde")] 852 fn finalize_body_candidate_ids(body: &mut TradeMutationBodyV1) -> Result<(), TradeProtocolError> { 853 match body { 854 TradeMutationBodyV1::Proposal { candidate } 855 | TradeMutationBodyV1::RevisionProposal { candidate } => { 856 let candidate_id = canonical_trade_candidate_id(candidate)?; 857 candidate.candidate_id = Some(candidate_id); 858 } 859 _ => {} 860 } 861 Ok(()) 862 } 863 864 #[cfg(feature = "serde")] 865 fn verify_candidate_ids(body: &TradeMutationBodyV1) -> Result<(), TradeProtocolError> { 866 match body { 867 TradeMutationBodyV1::Proposal { candidate } 868 | TradeMutationBodyV1::RevisionProposal { candidate } => { 869 if let Some(declared) = &candidate.candidate_id { 870 let computed = canonical_trade_candidate_id(candidate)?; 871 if declared != &computed { 872 return Err(TradeProtocolError::CandidateIdMismatch { 873 declared: declared.to_string(), 874 computed: computed.to_string(), 875 }); 876 } 877 } 878 } 879 _ => {} 880 } 881 Ok(()) 882 } 883 884 fn validate_parent_mutation_ids( 885 mutation_id: Option<&MutationId>, 886 parents: &[MutationId], 887 ) -> Result<(), TradeProtocolError> { 888 if parents.len() > RADROOTS_TRADE_MAX_PARENT_MUTATIONS { 889 return Err(TradeProtocolError::TooManyParents { 890 max: RADROOTS_TRADE_MAX_PARENT_MUTATIONS, 891 actual: parents.len(), 892 }); 893 } 894 let mut previous: Option<&MutationId> = None; 895 for parent in parents { 896 if Some(parent) == mutation_id { 897 return Err(TradeProtocolError::SelfParent); 898 } 899 if let Some(previous) = previous { 900 match previous.cmp(parent) { 901 core::cmp::Ordering::Greater => { 902 return Err(TradeProtocolError::UnsortedParents); 903 } 904 core::cmp::Ordering::Equal => { 905 return Err(TradeProtocolError::DuplicateParent); 906 } 907 core::cmp::Ordering::Less => {} 908 } 909 } 910 previous = Some(parent); 911 } 912 Ok(()) 913 } 914 915 fn validate_sorted_unique_by<T, F>( 916 items: &[T], 917 mut key: F, 918 field: &'static str, 919 ) -> Result<(), TradeProtocolError> 920 where 921 F: FnMut(&T) -> &str, 922 { 923 let mut previous: Option<&str> = None; 924 for item in items { 925 let item_key = key(item); 926 validate_non_empty(item_key, field)?; 927 if let Some(previous) = previous 928 && previous >= item_key 929 { 930 return Err(TradeProtocolError::InvalidField(field)); 931 } 932 previous = Some(item_key); 933 } 934 Ok(()) 935 } 936 937 fn validate_non_empty(value: &str, field: &'static str) -> Result<(), TradeProtocolError> { 938 if value.trim().is_empty() { 939 Err(TradeProtocolError::EmptyField(field)) 940 } else { 941 Ok(()) 942 } 943 } 944 945 fn validate_decimal_string(value: &str, field: &'static str) -> Result<(), TradeProtocolError> { 946 validate_non_empty(value, field)?; 947 let mut chars = value.chars(); 948 if matches!(chars.clone().next(), Some('-')) { 949 chars.next(); 950 } 951 let mut saw_digit = false; 952 for character in chars { 953 match character { 954 '0'..='9' => saw_digit = true, 955 _ => return Err(TradeProtocolError::InvalidField(field)), 956 } 957 } 958 if saw_digit { 959 Ok(()) 960 } else { 961 Err(TradeProtocolError::InvalidField(field)) 962 } 963 } 964 965 fn validate_sha256_hex(value: &str, field: &'static str) -> Result<(), TradeProtocolError> { 966 MutationId::parse(value) 967 .map(|_| ()) 968 .map_err(|error| TradeProtocolError::InvalidIdentifier { field, error }) 969 } 970 971 #[cfg(feature = "serde")] 972 struct NoDuplicateJsonValue(Value); 973 974 #[cfg(feature = "serde")] 975 impl<'de> Deserialize<'de> for NoDuplicateJsonValue { 976 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 977 where 978 D: Deserializer<'de>, 979 { 980 deserializer.deserialize_any(NoDuplicateJsonValueVisitor) 981 } 982 } 983 984 #[cfg(feature = "serde")] 985 struct NoDuplicateJsonValueVisitor; 986 987 #[cfg(feature = "serde")] 988 impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor { 989 type Value = NoDuplicateJsonValue; 990 991 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 992 formatter.write_str("JSON value without duplicate object keys") 993 } 994 995 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { 996 Ok(NoDuplicateJsonValue(Value::Bool(value))) 997 } 998 999 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { 1000 Ok(NoDuplicateJsonValue(Value::Number(value.into()))) 1001 } 1002 1003 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { 1004 Ok(NoDuplicateJsonValue(Value::Number(value.into()))) 1005 } 1006 1007 fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> 1008 where 1009 E: serde::de::Error, 1010 { 1011 Err(E::custom("unsupported number")) 1012 } 1013 1014 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { 1015 Ok(NoDuplicateJsonValue(Value::String(value.to_string()))) 1016 } 1017 1018 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { 1019 Ok(NoDuplicateJsonValue(Value::String(value))) 1020 } 1021 1022 fn visit_none<E>(self) -> Result<Self::Value, E> { 1023 Ok(NoDuplicateJsonValue(Value::Null)) 1024 } 1025 1026 fn visit_unit<E>(self) -> Result<Self::Value, E> { 1027 Ok(NoDuplicateJsonValue(Value::Null)) 1028 } 1029 1030 fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error> 1031 where 1032 D: Deserializer<'de>, 1033 { 1034 Deserialize::deserialize(deserializer) 1035 } 1036 1037 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> 1038 where 1039 A: SeqAccess<'de>, 1040 { 1041 let mut values = Vec::new(); 1042 while let Some(value) = sequence.next_element::<NoDuplicateJsonValue>()? { 1043 values.push(value.0); 1044 } 1045 Ok(NoDuplicateJsonValue(Value::Array(values))) 1046 } 1047 1048 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> 1049 where 1050 A: MapAccess<'de>, 1051 { 1052 let mut values = BTreeMap::new(); 1053 while let Some(key) = map.next_key::<String>()? { 1054 if values.contains_key(&key) { 1055 return Err(A::Error::custom(format!("duplicate key: {key}"))); 1056 } 1057 let value = map.next_value::<NoDuplicateJsonValue>()?; 1058 values.insert(key, value.0); 1059 } 1060 let mut object = Map::new(); 1061 for (key, value) in values { 1062 object.insert(key, value); 1063 } 1064 Ok(NoDuplicateJsonValue(Value::Object(object))) 1065 } 1066 } 1067 1068 #[cfg(all(test, feature = "serde"))] 1069 #[cfg_attr(coverage_nightly, coverage(off))] 1070 mod tests { 1071 use super::*; 1072 use crate::id::parse_public_key; 1073 1074 fn hex_64(character: char) -> String { 1075 core::iter::repeat_n(character, 64).collect() 1076 } 1077 1078 fn hex_32(character: char) -> String { 1079 core::iter::repeat_n(character, 32).collect() 1080 } 1081 1082 fn pubkey(character: char) -> PublicKey { 1083 parse_public_key(crate::test_valid_hex_64(character)).unwrap() 1084 } 1085 1086 fn event_id(character: char) -> EventId { 1087 EventId::parse(hex_64(character)).unwrap() 1088 } 1089 1090 fn trade_id() -> TradeId { 1091 TradeId::parse(hex_32('1')).unwrap() 1092 } 1093 1094 fn mutation_id(character: char) -> MutationId { 1095 MutationId::parse(hex_64(character)).unwrap() 1096 } 1097 1098 fn candidate_id(character: char) -> CandidateId { 1099 CandidateId::parse(hex_64(character)).unwrap() 1100 } 1101 1102 fn candidate() -> TradeCandidateTermsV1 { 1103 TradeCandidateTermsV1 { 1104 candidate_id: None, 1105 schema_version: RADROOTS_TRADE_SCHEMA_VERSION, 1106 base_candidate_id: None, 1107 supersession_intent: None, 1108 buyer_pubkey: pubkey('a'), 1109 seller_pubkey: pubkey('b'), 1110 farm_id: DTag::parse("farm-1").unwrap(), 1111 lines: vec![TradeCandidateLineV1 { 1112 line_id: DTag::parse("line-1").unwrap(), 1113 listing_addr: ClassifiedListingAddress::parse(format!( 1114 "30402:{}:listing-1", 1115 crate::test_valid_hex_64('b') 1116 )) 1117 .unwrap(), 1118 listing_event_id: event_id('c'), 1119 listing_snapshot_sha256: hex_64('d'), 1120 product_id: "carrots".to_string(), 1121 option_id: None, 1122 bin_id: InventoryBinId::parse("bin-1").unwrap(), 1123 quantity_mantissa: "2".to_string(), 1124 quantity_scale: 0, 1125 unit_code: "count".to_string(), 1126 unit_profile: "mvp-count".to_string(), 1127 unit_price_mantissa: "500".to_string(), 1128 currency_code: "USD".to_string(), 1129 line_subtotal_mantissa: "1000".to_string(), 1130 replaces_line_id: None, 1131 }], 1132 line_tombstones: Vec::new(), 1133 economics: TradeEconomicsProfileV1 { 1134 profile_id: "mvp-fixed".to_string(), 1135 currency_code: "USD".to_string(), 1136 currency_exponent: 2, 1137 rounding_profile: "half-even".to_string(), 1138 subtotal_mantissa: "1000".to_string(), 1139 discount_total_mantissa: "0".to_string(), 1140 adjustment_total_mantissa: "0".to_string(), 1141 total_mantissa: "1000".to_string(), 1142 adjustments: Vec::new(), 1143 }, 1144 fulfillment: FulfillmentProfileV1 { 1145 profile_id: "market-pickup".to_string(), 1146 method: "pickup".to_string(), 1147 starts_at_unix_s: 1_800_000_000, 1148 ends_at_unix_s: 1_800_003_600, 1149 timezone: "America/New_York".to_string(), 1150 utc_offset_seconds: -18_000, 1151 fold: 0, 1152 location_class: "farmstand".to_string(), 1153 requires_private_terms: false, 1154 }, 1155 cancellation: TradeCancellationProfileV1 { 1156 profile_id: "buyer-pre-agreement".to_string(), 1157 buyer_pre_agreement: true, 1158 post_agreement_cutoff_unix_s: None, 1159 }, 1160 private_terms: None, 1161 proposal_expires_at_unix_s: 1_799_999_000, 1162 } 1163 } 1164 1165 fn proposal() -> TradeMutationEnvelopeV1 { 1166 TradeMutationEnvelopeV1 { 1167 mutation_id: None, 1168 contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string(), 1169 schema_version: RADROOTS_TRADE_SCHEMA_VERSION, 1170 trade_id: trade_id(), 1171 root_mutation_id: None, 1172 buyer_pubkey: pubkey('a'), 1173 seller_pubkey: pubkey('b'), 1174 farm_id: DTag::parse("farm-1").unwrap(), 1175 parent_mutation_ids: Vec::new(), 1176 author_pubkey: pubkey('a'), 1177 counterparty_pubkey: pubkey('b'), 1178 authored_at_unix_s: 1_799_000_000, 1179 body: TradeMutationBodyV1::Proposal { 1180 candidate: candidate(), 1181 }, 1182 } 1183 } 1184 1185 fn adjustment(id: &str) -> TradeEconomicAdjustmentV1 { 1186 TradeEconomicAdjustmentV1 { 1187 adjustment_id: DTag::parse(id).unwrap(), 1188 actor: "seller".to_owned(), 1189 effect: "charge".to_owned(), 1190 amount_mantissa: "10".to_owned(), 1191 reason: "packing".to_owned(), 1192 } 1193 } 1194 1195 fn reservation_assertion() -> SellerReservationAssertionV1 { 1196 SellerReservationAssertionV1 { 1197 reservation_id: DTag::parse("reservation-1").unwrap(), 1198 inventory_authority_id: pubkey('c'), 1199 inventory_epoch: 1, 1200 candidate_id: candidate_id('d'), 1201 commitments: vec![SellerReservationLineV1 { 1202 line_id: DTag::parse("line-1").unwrap(), 1203 bin_id: InventoryBinId::parse("bin-1").unwrap(), 1204 quantity_mantissa: "2".to_owned(), 1205 quantity_scale: 0, 1206 unit_code: "count".to_owned(), 1207 }], 1208 reservation_expires_at_unix_s: 1_800_000_000, 1209 assertion_commitment: hex_64('e'), 1210 } 1211 } 1212 1213 fn child_envelope(body: TradeMutationBodyV1) -> TradeMutationEnvelopeV1 { 1214 let mut envelope = proposal(); 1215 envelope.contract_id = body.mutation_kind().contract_id().to_owned(); 1216 envelope.root_mutation_id = Some(mutation_id('a')); 1217 envelope.parent_mutation_ids = vec![mutation_id('a')]; 1218 envelope.body = body; 1219 envelope 1220 } 1221 1222 #[test] 1223 fn canonical_json_sorts_keys_and_rejects_duplicate_keys() { 1224 assert_eq!( 1225 canonical_jcs_from_str(r#"{"z":1,"a":["b",true,null]}"#).unwrap(), 1226 r#"{"a":["b",true,null],"z":1}"# 1227 ); 1228 assert!(matches!( 1229 canonical_jcs_from_str(r#"{"a":1,"a":2}"#), 1230 Err(TradeProtocolError::DuplicateKey(_)) 1231 )); 1232 } 1233 1234 #[test] 1235 fn canonical_trade_mutation_sets_candidate_and_mutation_ids() { 1236 let canonical = canonical_trade_mutation_content(proposal()).unwrap(); 1237 assert!(canonical.envelope.mutation_id.is_some()); 1238 assert_eq!( 1239 canonical.mutation_id, 1240 canonical.envelope.mutation_id.unwrap() 1241 ); 1242 let parsed = trade_mutation_from_canonical_content(&canonical.content).unwrap(); 1243 assert_eq!(parsed.mutation_id, Some(canonical.mutation_id)); 1244 } 1245 1246 #[test] 1247 fn canonical_trade_mutation_rejects_parent_ordering() { 1248 let mut envelope = proposal(); 1249 envelope.contract_id = RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID.to_string(); 1250 envelope.root_mutation_id = Some(MutationId::parse(hex_64('a')).unwrap()); 1251 envelope.parent_mutation_ids = vec![ 1252 MutationId::parse(hex_64('b')).unwrap(), 1253 MutationId::parse(hex_64('a')).unwrap(), 1254 ]; 1255 envelope.body = TradeMutationBodyV1::RevisionProposal { 1256 candidate: candidate(), 1257 }; 1258 assert!(matches!( 1259 canonical_trade_mutation_content(envelope), 1260 Err(TradeProtocolError::UnsortedParents) 1261 )); 1262 } 1263 1264 #[test] 1265 #[cfg_attr(coverage_nightly, coverage(off))] 1266 fn trade_validation_contract_covers_every_mutation_and_parent_rule() { 1267 let kinds = [ 1268 TradeMutationKindV1::Proposal, 1269 TradeMutationKindV1::Decision, 1270 TradeMutationKindV1::RevisionProposal, 1271 TradeMutationKindV1::RevisionDecision, 1272 TradeMutationKindV1::Cancellation, 1273 ]; 1274 for kind in kinds { 1275 assert!(!kind.contract_id().is_empty()); 1276 assert_ne!(kind.nostr_kind(), 0); 1277 } 1278 1279 let mut envelope = proposal(); 1280 envelope.schema_version += 1; 1281 assert!(matches!( 1282 envelope.validate(), 1283 Err(TradeProtocolError::InvalidSchemaVersion { .. }) 1284 )); 1285 let mut envelope = proposal(); 1286 envelope.contract_id = "wrong".to_owned(); 1287 assert!(matches!( 1288 envelope.validate(), 1289 Err(TradeProtocolError::ContractMismatch { .. }) 1290 )); 1291 let mut envelope = proposal(); 1292 envelope.root_mutation_id = Some(mutation_id('a')); 1293 assert_eq!( 1294 envelope.validate(), 1295 Err(TradeProtocolError::InvalidInitialParents) 1296 ); 1297 let mut envelope = proposal(); 1298 envelope.parent_mutation_ids = vec![mutation_id('a')]; 1299 assert_eq!( 1300 envelope.validate(), 1301 Err(TradeProtocolError::InvalidInitialParents) 1302 ); 1303 1304 let mut envelope = child_envelope(TradeMutationBodyV1::Decision { 1305 proposal_mutation_id: mutation_id('a'), 1306 candidate_id: candidate_id('a'), 1307 decision: TradeDecisionV1::Accepted { 1308 reservation_assertion: None, 1309 }, 1310 }); 1311 assert!(envelope.validate().is_ok()); 1312 envelope.root_mutation_id = None; 1313 assert_eq!( 1314 envelope.validate(), 1315 Err(TradeProtocolError::MissingParentMutation) 1316 ); 1317 let mut envelope = child_envelope(TradeMutationBodyV1::Decision { 1318 proposal_mutation_id: mutation_id('a'), 1319 candidate_id: candidate_id('a'), 1320 decision: TradeDecisionV1::Accepted { 1321 reservation_assertion: None, 1322 }, 1323 }); 1324 envelope.parent_mutation_ids.clear(); 1325 assert_eq!( 1326 envelope.validate(), 1327 Err(TradeProtocolError::MissingParentMutation) 1328 ); 1329 1330 let revision_decision = child_envelope(TradeMutationBodyV1::RevisionDecision { 1331 proposal_mutation_id: mutation_id('a'), 1332 candidate_id: candidate_id('a'), 1333 decision: TradeDecisionV1::Declined { 1334 reason: "inventory unavailable".to_owned(), 1335 }, 1336 }); 1337 assert!(revision_decision.validate().is_ok()); 1338 let revision = child_envelope(TradeMutationBodyV1::RevisionProposal { 1339 candidate: candidate(), 1340 }); 1341 assert!(revision.validate().is_ok()); 1342 1343 for body in [ 1344 TradeMutationBodyV1::Cancellation { 1345 target_candidate_id: Some(candidate_id('a')), 1346 target_claim_mutation_id: None, 1347 reason: "cancelled".to_owned(), 1348 }, 1349 TradeMutationBodyV1::Cancellation { 1350 target_candidate_id: None, 1351 target_claim_mutation_id: Some(mutation_id('a')), 1352 reason: "cancelled".to_owned(), 1353 }, 1354 ] { 1355 assert!(child_envelope(body).validate().is_ok()); 1356 } 1357 assert_eq!( 1358 TradeMutationBodyV1::Cancellation { 1359 target_candidate_id: None, 1360 target_claim_mutation_id: None, 1361 reason: "cancelled".to_owned(), 1362 } 1363 .validate(), 1364 Err(TradeProtocolError::MissingCancellationTarget) 1365 ); 1366 assert!( 1367 TradeMutationBodyV1::Cancellation { 1368 target_candidate_id: Some(candidate_id('a')), 1369 target_claim_mutation_id: None, 1370 reason: " ".to_owned(), 1371 } 1372 .validate() 1373 .is_err() 1374 ); 1375 1376 assert_eq!( 1377 validate_parent_mutation_ids( 1378 None, 1379 &[ 1380 mutation_id('a'), 1381 mutation_id('b'), 1382 mutation_id('c'), 1383 mutation_id('d'), 1384 mutation_id('e'), 1385 ], 1386 ), 1387 Err(TradeProtocolError::TooManyParents { 1388 max: RADROOTS_TRADE_MAX_PARENT_MUTATIONS, 1389 actual: 5, 1390 }) 1391 ); 1392 assert_eq!( 1393 validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('a')]), 1394 Err(TradeProtocolError::DuplicateParent) 1395 ); 1396 assert_eq!( 1397 validate_parent_mutation_ids(Some(&mutation_id('a')), &[mutation_id('a')]), 1398 Err(TradeProtocolError::SelfParent) 1399 ); 1400 assert!(validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('b')]).is_ok()); 1401 assert!(validate_sorted_unique_by(&["a", "b"], |value| *value, "values").is_ok()); 1402 } 1403 1404 #[test] 1405 #[cfg_attr(coverage_nightly, coverage(off))] 1406 fn trade_candidate_validation_covers_every_nested_profile() { 1407 let base = candidate(); 1408 assert!(base.validate().is_ok()); 1409 1410 let mut invalid = base.clone(); 1411 invalid.schema_version += 1; 1412 assert!(invalid.validate().is_err()); 1413 let mut invalid = base.clone(); 1414 invalid.lines.clear(); 1415 assert_eq!(invalid.validate(), Err(TradeProtocolError::MissingLines)); 1416 let mut invalid = base.clone(); 1417 invalid.lines = vec![base.lines[0].clone(); RADROOTS_TRADE_MAX_ACTIVE_LINES + 1]; 1418 assert!(matches!( 1419 invalid.validate(), 1420 Err(TradeProtocolError::TooManyLines { .. }) 1421 )); 1422 let mut invalid = base.clone(); 1423 invalid.lines.push(base.lines[0].clone()); 1424 assert!(matches!( 1425 invalid.validate(), 1426 Err(TradeProtocolError::InvalidField("lines")) 1427 )); 1428 1429 let tombstone = TradeLineTombstoneV1 { 1430 line_id: DTag::parse("line-2").unwrap(), 1431 reason: "removed".to_owned(), 1432 }; 1433 let mut with_tombstone = base.clone(); 1434 with_tombstone.line_tombstones.push(tombstone.clone()); 1435 assert!(with_tombstone.validate().is_ok()); 1436 let mut invalid = with_tombstone.clone(); 1437 invalid.line_tombstones.push(tombstone); 1438 assert!(invalid.validate().is_err()); 1439 let mut invalid = with_tombstone; 1440 invalid.line_tombstones[0].reason = " ".to_owned(); 1441 assert!(invalid.validate().is_err()); 1442 1443 let mut line = base.lines[0].clone(); 1444 line.option_id = Some("option-1".to_owned()); 1445 assert!(line.validate().is_ok()); 1446 for mutate in [ 1447 |line: &mut TradeCandidateLineV1| line.listing_snapshot_sha256 = "bad".to_owned(), 1448 |line: &mut TradeCandidateLineV1| line.product_id = " ".to_owned(), 1449 |line: &mut TradeCandidateLineV1| line.option_id = Some(" ".to_owned()), 1450 |line: &mut TradeCandidateLineV1| line.quantity_mantissa = "1.5".to_owned(), 1451 |line: &mut TradeCandidateLineV1| line.unit_code = " ".to_owned(), 1452 |line: &mut TradeCandidateLineV1| line.unit_profile = " ".to_owned(), 1453 |line: &mut TradeCandidateLineV1| line.unit_price_mantissa = "-".to_owned(), 1454 |line: &mut TradeCandidateLineV1| line.currency_code = " ".to_owned(), 1455 |line: &mut TradeCandidateLineV1| line.line_subtotal_mantissa = "x".to_owned(), 1456 ] { 1457 let mut line = base.lines[0].clone(); 1458 mutate(&mut line); 1459 assert!(line.validate().is_err()); 1460 } 1461 let mut line = base.lines[0].clone(); 1462 line.quantity_mantissa = "-2".to_owned(); 1463 assert!(line.validate().is_ok()); 1464 1465 let economics = base.economics.clone(); 1466 for mutate in [ 1467 |value: &mut TradeEconomicsProfileV1| value.profile_id = " ".to_owned(), 1468 |value: &mut TradeEconomicsProfileV1| value.currency_code = " ".to_owned(), 1469 |value: &mut TradeEconomicsProfileV1| value.rounding_profile = " ".to_owned(), 1470 |value: &mut TradeEconomicsProfileV1| value.subtotal_mantissa = "x".to_owned(), 1471 |value: &mut TradeEconomicsProfileV1| value.discount_total_mantissa = "x".to_owned(), 1472 |value: &mut TradeEconomicsProfileV1| value.adjustment_total_mantissa = "x".to_owned(), 1473 |value: &mut TradeEconomicsProfileV1| value.total_mantissa = "x".to_owned(), 1474 ] { 1475 let mut value = economics.clone(); 1476 mutate(&mut value); 1477 assert!(value.validate().is_err()); 1478 } 1479 let mut value = economics.clone(); 1480 value.adjustments = vec![adjustment("adjustment-1"); RADROOTS_TRADE_MAX_ADJUSTMENTS + 1]; 1481 assert!(matches!( 1482 value.validate(), 1483 Err(TradeProtocolError::TooManyAdjustments { .. }) 1484 )); 1485 let mut value = economics.clone(); 1486 value.adjustments = vec![adjustment("adjustment-1"), adjustment("adjustment-1")]; 1487 assert!(value.validate().is_err()); 1488 let mut value = economics.clone(); 1489 value.adjustments = vec![adjustment("adjustment-1")]; 1490 assert!(value.validate().is_ok()); 1491 for mutate in [ 1492 |value: &mut TradeEconomicAdjustmentV1| value.actor = " ".to_owned(), 1493 |value: &mut TradeEconomicAdjustmentV1| value.effect = " ".to_owned(), 1494 |value: &mut TradeEconomicAdjustmentV1| value.amount_mantissa = "x".to_owned(), 1495 |value: &mut TradeEconomicAdjustmentV1| value.reason = " ".to_owned(), 1496 ] { 1497 let mut value = adjustment("adjustment-1"); 1498 mutate(&mut value); 1499 assert!(value.validate().is_err()); 1500 } 1501 1502 let fulfillment = base.fulfillment.clone(); 1503 for mutate in [ 1504 |value: &mut FulfillmentProfileV1| value.profile_id = " ".to_owned(), 1505 |value: &mut FulfillmentProfileV1| value.method = " ".to_owned(), 1506 |value: &mut FulfillmentProfileV1| value.timezone = " ".to_owned(), 1507 |value: &mut FulfillmentProfileV1| value.location_class = " ".to_owned(), 1508 |value: &mut FulfillmentProfileV1| value.ends_at_unix_s = value.starts_at_unix_s, 1509 |value: &mut FulfillmentProfileV1| value.fold = 2, 1510 ] { 1511 let mut value = fulfillment.clone(); 1512 mutate(&mut value); 1513 assert!(value.validate().is_err()); 1514 } 1515 let mut cancellation = base.cancellation.clone(); 1516 cancellation.profile_id = " ".to_owned(); 1517 assert!(cancellation.validate().is_err()); 1518 1519 let private_terms = TradePrivateTermsRefV1 { 1520 artifact_id: "artifact-1".to_owned(), 1521 schema_id: "schema-1".to_owned(), 1522 ciphertext_commitment: hex_64('a'), 1523 required_acknowledgement: true, 1524 }; 1525 let mut with_private = base.clone(); 1526 with_private.private_terms = Some(private_terms.clone()); 1527 assert!(with_private.validate().is_ok()); 1528 for mutate in [ 1529 |value: &mut TradePrivateTermsRefV1| value.artifact_id = " ".to_owned(), 1530 |value: &mut TradePrivateTermsRefV1| value.schema_id = " ".to_owned(), 1531 |value: &mut TradePrivateTermsRefV1| value.ciphertext_commitment = "bad".to_owned(), 1532 ] { 1533 let mut value = private_terms.clone(); 1534 mutate(&mut value); 1535 assert!(value.validate().is_err()); 1536 } 1537 } 1538 1539 #[test] 1540 fn trade_candidate_deserialization_rejects_non_classified_listing_coordinates() { 1541 let mut value = serde_json::to_value(candidate()).expect("candidate json"); 1542 value["lines"][0]["listing_addr"] = 1543 serde_json::json!(format!("30023:{}:listing-1", crate::test_valid_hex_64('b'))); 1544 1545 assert!(serde_json::from_value::<TradeCandidateTermsV1>(value).is_err()); 1546 } 1547 1548 #[test] 1549 #[cfg_attr(coverage_nightly, coverage(off))] 1550 fn trade_decision_and_reservation_contracts_cover_all_paths() { 1551 let assertion = reservation_assertion(); 1552 assert!(assertion.validate().is_ok()); 1553 assert!( 1554 TradeDecisionV1::Accepted { 1555 reservation_assertion: None, 1556 } 1557 .validate() 1558 .is_ok() 1559 ); 1560 assert!( 1561 TradeDecisionV1::Accepted { 1562 reservation_assertion: Some(assertion.clone()), 1563 } 1564 .validate() 1565 .is_ok() 1566 ); 1567 assert!( 1568 TradeDecisionV1::Declined { 1569 reason: "declined".to_owned(), 1570 } 1571 .validate() 1572 .is_ok() 1573 ); 1574 assert!( 1575 TradeDecisionV1::Declined { 1576 reason: " ".to_owned(), 1577 } 1578 .validate() 1579 .is_err() 1580 ); 1581 1582 let mut invalid = assertion.clone(); 1583 invalid.commitments.clear(); 1584 assert_eq!( 1585 invalid.validate(), 1586 Err(TradeProtocolError::MissingReservationCommitments) 1587 ); 1588 let mut invalid = assertion.clone(); 1589 invalid.commitments.push(invalid.commitments[0].clone()); 1590 assert!(invalid.validate().is_err()); 1591 let mut invalid = assertion.clone(); 1592 invalid.commitments[0].quantity_mantissa = "x".to_owned(); 1593 assert!(invalid.validate().is_err()); 1594 let mut invalid = assertion.clone(); 1595 invalid.commitments[0].unit_code = " ".to_owned(); 1596 assert!(invalid.validate().is_err()); 1597 let mut invalid = assertion; 1598 invalid.assertion_commitment = "bad".to_owned(); 1599 assert!(invalid.validate().is_err()); 1600 } 1601 1602 #[test] 1603 #[cfg_attr(coverage_nightly, coverage(off))] 1604 fn trade_canonicalization_and_error_contracts_cover_all_paths() { 1605 use serde::de::{Unexpected, value::StrDeserializer}; 1606 1607 let canonical = canonical_trade_mutation_content(proposal()).expect("canonical proposal"); 1608 assert_eq!( 1609 canonical_trade_candidate_id(match &canonical.envelope.body { 1610 TradeMutationBodyV1::Proposal { candidate } => candidate, 1611 _ => unreachable!("proposal"), 1612 }) 1613 .expect("candidate id"), 1614 match &canonical.envelope.body { 1615 TradeMutationBodyV1::Proposal { candidate } => { 1616 candidate.candidate_id.expect("candidate id") 1617 } 1618 _ => unreachable!("proposal"), 1619 } 1620 ); 1621 assert_eq!( 1622 canonical_trade_mutation_id(&canonical.envelope).expect("mutation id"), 1623 canonical.mutation_id 1624 ); 1625 1626 assert!(matches!( 1627 trade_mutation_from_canonical_content( 1628 &"x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1) 1629 ), 1630 Err(TradeProtocolError::ContentTooLarge { .. }) 1631 )); 1632 assert_eq!( 1633 trade_mutation_from_canonical_content(" {} "), 1634 Err(TradeProtocolError::NonCanonicalJson) 1635 ); 1636 assert!(matches!( 1637 trade_mutation_from_canonical_content("{}"), 1638 Err(TradeProtocolError::InvalidJson(_)) 1639 )); 1640 assert!(matches!( 1641 canonical_jcs_from_str("{"), 1642 Err(TradeProtocolError::InvalidJson(_)) 1643 )); 1644 assert!(matches!( 1645 canonical_jcs_from_str("1.5"), 1646 Err(TradeProtocolError::InvalidJson(_)) 1647 )); 1648 assert_eq!( 1649 canonical_jcs_value(&serde_json::json!(1.5)), 1650 Err(TradeProtocolError::UnsupportedNumber) 1651 ); 1652 assert_eq!( 1653 canonical_jcs_value(&Value::Number(Number::from(u64::MAX))).expect("large integer"), 1654 u64::MAX.to_string() 1655 ); 1656 assert!(remove_object_field(&mut Value::Null, "id").is_err()); 1657 1658 let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json"); 1659 value["body"]["candidate"]["candidate_id"] = Value::String(hex_64('f')); 1660 let wrong_candidate = canonical_jcs_value(&value).expect("wrong candidate json"); 1661 assert!(matches!( 1662 trade_mutation_from_canonical_content(&wrong_candidate), 1663 Err(TradeProtocolError::CandidateIdMismatch { .. }) 1664 )); 1665 1666 let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json"); 1667 value["mutation_id"] = Value::String(hex_64('f')); 1668 let wrong_mutation = canonical_jcs_value(&value).expect("wrong mutation json"); 1669 assert!(matches!( 1670 trade_mutation_from_canonical_content(&wrong_mutation), 1671 Err(TradeProtocolError::MutationIdMismatch { .. }) 1672 )); 1673 1674 let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json"); 1675 value["mutation_id"] = Value::Null; 1676 value["body"]["candidate"]["candidate_id"] = Value::Null; 1677 let undeclared_ids = canonical_jcs_value(&value).expect("undeclared ids json"); 1678 assert!(trade_mutation_from_canonical_content(&undeclared_ids).is_ok()); 1679 1680 let decision = child_envelope(TradeMutationBodyV1::Decision { 1681 proposal_mutation_id: mutation_id('a'), 1682 candidate_id: candidate_id('a'), 1683 decision: TradeDecisionV1::Accepted { 1684 reservation_assertion: None, 1685 }, 1686 }); 1687 let decision = canonical_trade_mutation_content(decision).expect("canonical decision"); 1688 assert!(trade_mutation_from_canonical_content(&decision.content).is_ok()); 1689 1690 let revision = child_envelope(TradeMutationBodyV1::RevisionProposal { 1691 candidate: candidate(), 1692 }); 1693 let revision = canonical_trade_mutation_content(revision).expect("canonical revision"); 1694 assert!(trade_mutation_from_canonical_content(&revision.content).is_ok()); 1695 1696 let mut oversized = proposal(); 1697 if let TradeMutationBodyV1::Proposal { candidate } = &mut oversized.body { 1698 candidate.lines[0].product_id = "x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1); 1699 } 1700 assert!(matches!( 1701 canonical_trade_mutation_content(oversized), 1702 Err(TradeProtocolError::ContentTooLarge { .. }) 1703 )); 1704 1705 let string_value: Result<NoDuplicateJsonValue, serde_json::Error> = 1706 NoDuplicateJsonValueVisitor.visit_string("value".to_owned()); 1707 assert_eq!( 1708 string_value.expect("string").0, 1709 Value::String("value".to_owned()) 1710 ); 1711 let none_value: Result<NoDuplicateJsonValue, serde_json::Error> = 1712 NoDuplicateJsonValueVisitor.visit_none(); 1713 assert_eq!(none_value.expect("none").0, Value::Null); 1714 let some_value: Result<NoDuplicateJsonValue, serde_json::Error> = 1715 NoDuplicateJsonValueVisitor.visit_some(StrDeserializer::new("value")); 1716 assert_eq!( 1717 some_value.expect("some").0, 1718 Value::String("value".to_owned()) 1719 ); 1720 let expected = <serde_json::Error as serde::de::Error>::invalid_type( 1721 Unexpected::Bool(true), 1722 &NoDuplicateJsonValueVisitor, 1723 ); 1724 assert!(expected.to_string().contains("JSON value")); 1725 1726 let parse_error = MutationId::parse("bad").expect_err("invalid id"); 1727 let errors = [ 1728 TradeProtocolError::InvalidSchemaVersion { 1729 expected: 1, 1730 actual: 2, 1731 }, 1732 TradeProtocolError::ContractMismatch { 1733 expected: "expected", 1734 actual: "actual".to_owned(), 1735 }, 1736 TradeProtocolError::InvalidInitialParents, 1737 TradeProtocolError::MissingParentMutation, 1738 TradeProtocolError::TooManyParents { max: 1, actual: 2 }, 1739 TradeProtocolError::UnsortedParents, 1740 TradeProtocolError::DuplicateParent, 1741 TradeProtocolError::SelfParent, 1742 TradeProtocolError::MissingLines, 1743 TradeProtocolError::TooManyLines { max: 1, actual: 2 }, 1744 TradeProtocolError::TooManyAdjustments { max: 1, actual: 2 }, 1745 TradeProtocolError::DuplicateKey("key".to_owned()), 1746 TradeProtocolError::InvalidJson("json".to_owned()), 1747 TradeProtocolError::NonCanonicalJson, 1748 TradeProtocolError::UnsupportedNumber, 1749 TradeProtocolError::ContentTooLarge { max: 1, actual: 2 }, 1750 TradeProtocolError::EmptyField("field"), 1751 TradeProtocolError::InvalidField("field"), 1752 TradeProtocolError::InvalidIdentifier { 1753 field: "field", 1754 error: parse_error, 1755 }, 1756 TradeProtocolError::InvalidTimeRange, 1757 TradeProtocolError::MissingReservationCommitments, 1758 TradeProtocolError::MissingCancellationTarget, 1759 TradeProtocolError::CandidateIdMismatch { 1760 declared: "a".to_owned(), 1761 computed: "b".to_owned(), 1762 }, 1763 TradeProtocolError::MutationIdMismatch { 1764 declared: "a".to_owned(), 1765 computed: "b".to_owned(), 1766 }, 1767 ]; 1768 for error in errors { 1769 assert!(!error.to_string().is_empty()); 1770 } 1771 } 1772 }