lib

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

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 }