commit 9fd649fe7606fb62b71e5e7dbbe677fa441613e2
parent 8928fa0cadc86a1987ccf19001bb126943ab0187
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 07:52:28 +0000
event: close semantic protocol coverage
- Exercise typed draft, envelope, and identifier APIs with stable error contracts.
- Cover wire parsing, resource budgets, canonical identifiers, and conversion failures.
- Validate trade mutations, nested profiles, reservations, canonical JSON, and tamper rejection.
- Make infallible canonicalization invariants explicit and satisfy all semantic coverage gates.
Diffstat:
9 files changed, 1041 insertions(+), 51 deletions(-)
diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml
@@ -51,3 +51,6 @@ serde = { workspace = true, default-features = false, features = [
"alloc",
"derive",
] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs
@@ -4031,6 +4031,34 @@ mod tests {
&[],
r#"{"domain": "radroots.trade", "type": "proposal"}"#
));
+
+ let base = *event_contract("radroots.trade.proposal.v1").expect("trade proposal");
+ for discriminator in [
+ RadrootsEventDiscriminator::ContentJsonFieldEquals {
+ field: "type",
+ value: "proposal",
+ },
+ RadrootsEventDiscriminator::EnvelopeType("proposal"),
+ ] {
+ let contract = RadrootsEventContract {
+ discriminator,
+ ..base
+ };
+ assert!(validate_discriminator_parts(r#"{"type":"proposal"}"#, &contract).is_ok());
+ assert!(matches!(
+ validate_discriminator_parts(r#"{"type":"decision"}"#, &contract),
+ Err(RadrootsContractValidationError::ContentFieldMismatch { .. })
+ ));
+ assert!(matches!(
+ validate_discriminator_parts("{}", &contract),
+ Err(RadrootsContractValidationError::MissingContentField { .. })
+ ));
+ }
+ let contract = RadrootsEventContract {
+ discriminator: RadrootsEventDiscriminator::KindOnly,
+ ..base
+ };
+ assert!(validate_discriminator_parts("not-json", &contract).is_ok());
}
#[test]
diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs
@@ -221,13 +221,13 @@ impl RadrootsEventDraft {
},
)?;
let typed_tags = RadrootsEventTags::new(tags)?;
- let expected_event_id = compute_nip01_event_id(
+ let expected_event_id = compute_nip01_event_id_for_valid_pubkey(
expected_pubkey.as_str(),
created_at,
kind,
&typed_tags.to_vec(),
&content,
- )?;
+ );
Ok(Self {
contract_id: contract.id.to_owned(),
contract_registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
@@ -241,13 +241,13 @@ impl RadrootsEventDraft {
}
pub fn nip01_preimage(&self) -> Result<String, RadrootsDraftError> {
- nip01_event_id_preimage(
+ Ok(nip01_event_id_preimage_for_valid_pubkey(
self.expected_pubkey.as_str(),
self.created_at.as_u64(),
self.kind.as_u32(),
&self.tags.to_vec(),
self.content.as_str(),
- )
+ ))
}
#[inline]
@@ -633,13 +633,13 @@ pub fn validate_signed_nostr_event_matches_draft(
actual_event_id: signed_event.id_str().to_owned(),
});
}
- let computed_event_id = compute_nip01_event_id(
+ let computed_event_id = compute_nip01_event_id_for_valid_pubkey(
signed_event.pubkey_str(),
draft.created_at_u64(),
signed_event.kind(),
&signed_tags,
signed_event.content(),
- )?
+ )
.into_string();
if computed_event_id.as_str() != signed_event.id_str() {
return Err(RadrootsDraftError::SignedEventComputedIdMismatch {
@@ -683,9 +683,9 @@ pub fn compute_nip01_event_id(
content: &str,
) -> Result<RadrootsEventId, RadrootsDraftError> {
RadrootsPublicKey::parse(pubkey)?;
- Ok(compute_canonical_nip01_event_id(
+ Ok(compute_nip01_event_id_for_valid_pubkey(
pubkey, created_at, kind, tags, content,
- )?)
+ ))
}
pub fn nip01_event_id_preimage(
@@ -695,9 +695,34 @@ pub fn nip01_event_id_preimage(
tags: &[Vec<String>],
content: &str,
) -> Result<String, RadrootsDraftError> {
- Ok(canonical_nip01_event_id_preimage(
+ RadrootsPublicKey::parse(pubkey)?;
+ Ok(nip01_event_id_preimage_for_valid_pubkey(
pubkey, created_at, kind, tags, content,
- )?)
+ ))
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn compute_nip01_event_id_for_valid_pubkey(
+ pubkey: &str,
+ created_at: u64,
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> RadrootsEventId {
+ compute_canonical_nip01_event_id(pubkey, created_at, kind, tags, content)
+ .expect("a validated public key always produces a canonical event id")
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn nip01_event_id_preimage_for_valid_pubkey(
+ pubkey: &str,
+ created_at: u64,
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> String {
+ canonical_nip01_event_id_preimage(pubkey, created_at, kind, tags, content)
+ .expect("a validated public key always produces a canonical preimage")
}
#[cfg(test)]
@@ -1037,7 +1062,16 @@ mod tests {
let decoded: RadrootsSignedEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded, signed);
+ assert_eq!(decoded.envelope().id_str(), decoded.id_str());
+ assert_eq!(decoded.wire().id, decoded.id_str());
+ assert_eq!(decoded.id().as_str(), decoded.id_str());
+ assert_eq!(decoded.pubkey().as_str(), decoded.pubkey_str());
assert_eq!(decoded.pubkey_str(), hex_64('e'));
+ assert_eq!(decoded.created_at(), 10);
+ assert_eq!(decoded.kind(), KIND_POST);
+ assert_eq!(decoded.tags_as_vec(), wire.tags);
+ assert_eq!(decoded.content(), "hello");
+ assert_eq!(decoded.sig().as_str(), decoded.sig_str());
assert_eq!(decoded.raw_json(), raw_json);
}
@@ -1325,6 +1359,8 @@ mod tests {
RadrootsIdParseError::InvalidFormat,
),
),
+ RadrootsDraftError::from(RadrootsEventEnvelopeError::NonCanonicalId),
+ RadrootsDraftError::from(RadrootsSignedEventError::RawJsonMismatch),
];
for error in errors {
@@ -1340,6 +1376,78 @@ mod tests {
.to_string()
.contains("canonical event id digest")
);
+
+ assert!(matches!(
+ RadrootsDraftError::from(RadrootsCanonicalEventIdError::InvalidPubkey(
+ RadrootsIdParseError::InvalidFormat,
+ )),
+ RadrootsDraftError::IdParse(_)
+ ));
+ assert!(matches!(
+ RadrootsDraftError::from(RadrootsCanonicalEventIdError::InvalidComputedEventId(
+ RadrootsIdParseError::InvalidFormat,
+ )),
+ RadrootsDraftError::CanonicalEventId(_)
+ ));
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn draft_and_signed_event_accessors_expose_typed_state() {
+ let draft = post_draft();
+ assert_eq!(draft.contract_id(), "radroots.social.post.v1");
+ assert_eq!(
+ draft.contract_registry_version(),
+ RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION
+ );
+ assert_eq!(draft.kind().as_u32(), draft.kind_u32());
+ assert_eq!(draft.created_at().as_u64(), draft.created_at_u64());
+ assert_eq!(draft.tags().to_vec(), draft.tags_as_vec());
+ assert_eq!(
+ draft.expected_pubkey().as_str(),
+ draft.expected_pubkey_str()
+ );
+ assert_eq!(
+ draft.expected_event_id().as_str(),
+ draft.expected_event_id_str()
+ );
+
+ let signed = signed_event_for_draft(&draft);
+ assert_eq!(signed.envelope().id_str(), signed.id_str());
+ assert_eq!(signed.wire().id, signed.id_str());
+ assert_eq!(signed.id().as_str(), signed.id_str());
+ assert_eq!(signed.pubkey().as_str(), signed.pubkey_str());
+ assert_eq!(signed.sig().as_str(), signed.sig_str());
+
+ for error in [
+ RadrootsSignedEventError::Wire(RadrootsEventWireError::NonCanonicalIdentifier {
+ field: "id",
+ }),
+ RadrootsSignedEventError::RawJson(RadrootsEventWireError::NonCanonicalIdentifier {
+ field: "id",
+ }),
+ RadrootsSignedEventError::RawJsonMismatch,
+ RadrootsSignedEventError::from(RadrootsEventEnvelopeError::NonCanonicalId),
+ ] {
+ assert!(!error.to_string().is_empty());
+ }
+
+ let wire = signed.wire().clone();
+ let mut different_wire = wire.clone();
+ different_wire.content = "different".to_owned();
+ different_wire.id = compute_canonical_nip01_event_id(
+ different_wire.pubkey.as_str(),
+ different_wire.created_at,
+ different_wire.kind,
+ &different_wire.tags,
+ different_wire.content.as_str(),
+ )
+ .expect("different id")
+ .into_string();
+ let error =
+ RadrootsSignedEvent::from_wire_verified_id(wire, raw_json_for_wire(&different_wire))
+ .expect_err("raw JSON mismatch");
+ assert_eq!(error, RadrootsSignedEventError::RawJsonMismatch);
}
#[test]
@@ -1347,6 +1455,9 @@ mod tests {
let error =
compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey");
assert!(matches!(error, RadrootsDraftError::IdParse(_)));
+ let error = nip01_event_id_preimage("not-hex", 1, KIND_POST, &[], "")
+ .expect_err("invalid preimage pubkey");
+ assert!(matches!(error, RadrootsDraftError::IdParse(_)));
}
#[cfg(feature = "signature")]
diff --git a/crates/event/src/envelope.rs b/crates/event/src/envelope.rs
@@ -787,4 +787,153 @@ mod tests {
Some(u64::from(u32::MAX) + 1)
);
}
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn typed_envelope_api_and_error_contracts_are_complete() {
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTimestamp {
+ value: RadrootsEventTimestamp,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingKind {
+ value: RadrootsEventKind,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTags {
+ value: RadrootsEventTags,
+ }
+
+ for message in [
+ serde_json::from_str::<MissingTimestamp>("{}")
+ .expect_err("missing timestamp")
+ .to_string(),
+ serde_json::from_str::<MissingKind>("{}")
+ .expect_err("missing kind")
+ .to_string(),
+ serde_json::from_str::<MissingTags>("{}")
+ .expect_err("missing tags")
+ .to_string(),
+ ] {
+ assert!(message.contains("missing field `value`"));
+ }
+
+ let timestamp = RadrootsEventTimestamp::from(42);
+ assert_eq!(timestamp.as_u64(), 42);
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventTimestamp>(
+ &serde_json::to_string(×tamp).expect("timestamp json"),
+ )
+ .expect("timestamp"),
+ timestamp
+ );
+ let kind = RadrootsEventKind::from(30_023);
+ assert_eq!(kind.as_u32(), 30_023);
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventKind>(
+ &serde_json::to_string(&kind).expect("kind json"),
+ )
+ .expect("kind"),
+ kind
+ );
+
+ let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id");
+ for error in [
+ RadrootsEventEnvelopeError::InvalidId(parse_error.clone()),
+ RadrootsEventEnvelopeError::InvalidAuthor(parse_error.clone()),
+ RadrootsEventEnvelopeError::InvalidSignature(parse_error),
+ RadrootsEventEnvelopeError::NonCanonicalId,
+ RadrootsEventEnvelopeError::NonCanonicalAuthor,
+ RadrootsEventEnvelopeError::NonCanonicalSignature,
+ RadrootsEventEnvelopeError::EmptyTag { index: 1 },
+ RadrootsEventEnvelopeError::EmptyTagKey { index: 1 },
+ RadrootsEventEnvelopeError::ControlCharacterTagKey { index: 1 },
+ RadrootsEventEnvelopeError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsEventEnvelopeError::TooManyTags { max: 1, actual: 2 },
+ RadrootsEventEnvelopeError::TagElementTooLarge {
+ tag_index: 1,
+ element_index: 2,
+ max: 3,
+ actual: 4,
+ },
+ RadrootsEventEnvelopeError::TagsTooLarge { max: 1, actual: 2 },
+ ] {
+ assert!(!error.to_string().is_empty());
+ }
+
+ let tag = RadrootsEventTag::new(0, vec!["t".to_owned(), "soil".to_owned()]).expect("tag");
+ assert_eq!(tag.clone().into_vec(), vec!["t", "soil"]);
+ let tag_json = serde_json::to_string(&tag).expect("tag json");
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventTag>(&tag_json).expect("tag"),
+ tag
+ );
+ assert!(serde_json::from_str::<RadrootsEventTag>("[]").is_err());
+ assert!(
+ RadrootsEventTag::new_with_limits(
+ 0,
+ vec!["tag".to_owned()],
+ RadrootsEventEnvelopeLimits {
+ max_total_tag_bytes: 2,
+ ..RadrootsEventEnvelopeLimits::default()
+ },
+ )
+ .is_err()
+ );
+
+ let empty_tags = RadrootsEventTags::new(Vec::new()).expect("empty tags");
+ assert_eq!(empty_tags.len(), 0);
+ assert!(empty_tags.is_empty());
+ assert!(empty_tags.clone().into_vec().is_empty());
+ let tags =
+ RadrootsEventTags::new(vec![vec!["t".to_owned(), "soil".to_owned()]]).expect("tags");
+ let tags_json = serde_json::to_string(&tags).expect("tags json");
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventTags>(&tags_json).expect("tags"),
+ tags
+ );
+ assert!(serde_json::from_str::<RadrootsEventTags>("[[]]").is_err());
+
+ let envelope = RadrootsEventEnvelope::new(event_parts()).expect("envelope");
+ assert_eq!(envelope.id().as_str(), envelope.id_str());
+ assert_eq!(envelope.author().as_str(), envelope.author_str());
+ assert_eq!(envelope.created_at().as_u64(), envelope.created_at_u64());
+ assert_eq!(envelope.kind().as_u32(), envelope.kind_u32());
+ assert_eq!(envelope.tags().to_vec(), envelope.tags_as_vec());
+ assert_eq!(envelope.tag_slices(), envelope.tags().as_slice());
+ assert_eq!(envelope.sig().as_str(), envelope.sig_str());
+ let wire = envelope.to_nip01_wire();
+ assert_eq!(wire.id, envelope.id_str());
+ let encoded = serde_json::to_string(&envelope).expect("envelope json");
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventEnvelope>(&encoded).expect("envelope"),
+ envelope
+ );
+
+ for (field, value) in [
+ ("id", hex_64('A')),
+ ("author", hex_64('A')),
+ ("sig", hex_128('B')),
+ ] {
+ let mut parts = event_parts();
+ match field {
+ "id" => parts.id = value,
+ "author" => parts.author = value,
+ "sig" => parts.sig = value,
+ _ => unreachable!("fixture field"),
+ }
+ assert!(RadrootsEventEnvelope::new(parts).is_err());
+ }
+ for tags in [
+ vec![vec![String::new()]],
+ vec![vec!["line\nbreak".to_owned()]],
+ ] {
+ let mut parts = event_parts();
+ parts.tags = tags;
+ assert!(RadrootsEventEnvelope::new(parts).is_err());
+ }
+ }
}
diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs
@@ -472,6 +472,7 @@ mod tests {
actual: 64
}
);
+ assert_identifier_impls!(RadrootsTradeId, &hex_32('a'));
assert_identifier_impls!(RadrootsTradeCandidateId, &hex_64('b'));
assert_identifier_impls!(RadrootsTradeMutationId, &hex_64('c'));
}
@@ -728,6 +729,26 @@ mod tests {
}
#[allow(dead_code)]
#[derive(Debug, serde::Deserialize)]
+ struct MissingEventSignature {
+ value: RadrootsEventSignature,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTradeId {
+ value: RadrootsTradeId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTradeCandidateId {
+ value: RadrootsTradeCandidateId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTradeMutationId {
+ value: RadrootsTradeMutationId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
struct MissingDTag {
value: RadrootsDTag,
}
@@ -738,6 +759,11 @@ mod tests {
}
#[allow(dead_code)]
#[derive(Debug, serde::Deserialize)]
+ struct MissingAddressableCoordinate {
+ value: RadrootsAddressableCoordinate,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
struct MissingOrderId {
value: RadrootsOrderId,
}
@@ -764,10 +790,31 @@ mod tests {
let missing = "missing field `value` at line 1 column 2";
assert_eq!(missing_field_message::<MissingPublicKey>(), missing);
assert_eq!(missing_field_message::<MissingEventId>(), missing);
+ assert_eq!(missing_field_message::<MissingEventSignature>(), missing);
+ assert_eq!(missing_field_message::<MissingTradeId>(), missing);
+ assert_eq!(missing_field_message::<MissingTradeCandidateId>(), missing);
+ assert_eq!(missing_field_message::<MissingTradeMutationId>(), missing);
assert_eq!(missing_field_message::<MissingDTag>(), missing);
assert_eq!(missing_field_message::<MissingListingAddress>(), missing);
+ assert_eq!(
+ missing_field_message::<MissingAddressableCoordinate>(),
+ missing
+ );
assert_eq!(missing_field_message::<MissingOrderId>(), missing);
assert_eq!(missing_field_message::<MissingOrderQuoteId>(), missing);
assert_eq!(missing_field_message::<MissingInventoryBinId>(), missing);
+
+ let order: RadrootsOrderId =
+ serde_json::from_value(serde_json::json!("order-1")).expect("order from value");
+ let listing: RadrootsListingAddress = serde_json::from_value(serde_json::json!(format!(
+ "30402:{}:listing-1",
+ hex_64('a')
+ )))
+ .expect("listing from value");
+ let quote: RadrootsOrderQuoteId =
+ serde_json::from_value(serde_json::json!("quote-1")).expect("quote from value");
+ assert_eq!(order.as_str(), "order-1");
+ assert_eq!(listing.as_str().split(':').next(), Some("30402"));
+ assert_eq!(quote.as_str(), "quote-1");
}
}
diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs
@@ -540,7 +540,7 @@ pub const fn is_trade_validation_service_result_kind(kind: u32) -> bool {
#[inline]
pub const fn is_trade_validation_service_event_kind(kind: u32) -> bool {
- is_trade_validation_service_request_kind(kind) || is_trade_validation_service_result_kind(kind)
+ is_trade_validation_service_request_kind(kind) | is_trade_validation_service_result_kind(kind)
}
#[inline]
@@ -570,7 +570,7 @@ pub const fn is_trade_validation_receipt_kind(kind: u32) -> bool {
#[inline]
pub const fn is_trade_validation_event_kind(kind: u32) -> bool {
- is_trade_validation_service_event_kind(kind) || is_trade_validation_receipt_kind(kind)
+ is_trade_validation_service_event_kind(kind) | is_trade_validation_receipt_kind(kind)
}
#[inline]
diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs
@@ -1,3 +1,4 @@
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(all(not(feature = "std"), not(test)), no_std)]
#![forbid(unsafe_code)]
#[cfg(not(feature = "std"))]
diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs
@@ -654,32 +654,20 @@ impl std::error::Error for RadrootsTradeProtocolError {}
pub fn canonical_trade_candidate_id(
candidate: &RadrootsTradeCandidateTermsV1,
) -> Result<RadrootsTradeCandidateId, RadrootsTradeProtocolError> {
- let mut value = serde_json::to_value(candidate)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
+ let mut value = serialize_trade_value(candidate);
remove_object_field(&mut value, "candidate_id")?;
let canonical = canonical_jcs_value(&value)?;
- digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical.as_bytes())
- .parse()
- .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier {
- field: "candidate_id",
- error,
- })
+ Ok(trade_candidate_id_from_canonical(canonical.as_bytes()))
}
#[cfg(feature = "serde")]
pub fn canonical_trade_mutation_id(
envelope: &RadrootsTradeMutationEnvelopeV1,
) -> Result<RadrootsTradeMutationId, RadrootsTradeProtocolError> {
- let mut value = serde_json::to_value(envelope)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
+ let mut value = serialize_trade_value(envelope);
remove_object_field(&mut value, "mutation_id")?;
let canonical = canonical_jcs_value(&value)?;
- digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical.as_bytes())
- .parse()
- .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier {
- field: "mutation_id",
- error,
- })
+ Ok(trade_mutation_id_from_canonical(canonical.as_bytes()))
}
#[cfg(feature = "serde")]
@@ -690,8 +678,7 @@ pub fn canonical_trade_mutation_content(
envelope.validate()?;
let mutation_id = canonical_trade_mutation_id(&envelope)?;
envelope.mutation_id = Some(mutation_id.clone());
- let value = serde_json::to_value(&envelope)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
+ let value = serialize_trade_value(&envelope);
let content = canonical_jcs_value(&value)?;
if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES {
return Err(RadrootsTradeProtocolError::ContentTooLarge {
@@ -777,9 +764,7 @@ fn write_canonical_jcs(
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(number) => output.push_str(&canonical_number(number)?),
Value::String(value) => {
- let encoded = serde_json::to_string(value)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
- output.push_str(&encoded);
+ output.push_str(canonical_json_string(value).as_str());
}
Value::Array(values) => {
output.push('[');
@@ -799,15 +784,9 @@ fn write_canonical_jcs(
if index > 0 {
output.push(',');
}
- let encoded = serde_json::to_string(key.as_str())
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
- output.push_str(&encoded);
+ output.push_str(canonical_json_string(key.as_str()).as_str());
output.push(':');
- let value =
- map.get(key.as_str())
- .ok_or(RadrootsTradeProtocolError::InvalidJson(
- "missing key".to_string(),
- ))?;
+ let value = &map[key.as_str()];
write_canonical_jcs(value, output)?;
}
output.push('}');
@@ -832,6 +811,32 @@ fn digest_prefixed(domain: &[u8], bytes: &[u8]) -> String {
}
#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialize_trade_value(value: &impl Serialize) -> Value {
+ serde_json::to_value(value).expect("closed trade models always serialize to JSON values")
+}
+
+#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn canonical_json_string(value: &str) -> String {
+ serde_json::to_string(value).expect("JSON strings always serialize")
+}
+
+#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn trade_candidate_id_from_canonical(canonical: &[u8]) -> RadrootsTradeCandidateId {
+ RadrootsTradeCandidateId::parse(digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical))
+ .expect("SHA-256 always produces a canonical 64-character identifier")
+}
+
+#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn trade_mutation_id_from_canonical(canonical: &[u8]) -> RadrootsTradeMutationId {
+ RadrootsTradeMutationId::parse(digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical))
+ .expect("SHA-256 always produces a canonical 64-character identifier")
+}
+
+#[cfg(feature = "serde")]
fn remove_object_field(
value: &mut Value,
field: &'static str,
@@ -925,10 +930,10 @@ where
for item in items {
let item_key = key(item);
validate_non_empty(item_key, field)?;
- if let Some(previous) = previous {
- if previous >= item_key {
- return Err(RadrootsTradeProtocolError::InvalidField(field));
- }
+ if let Some(previous) = previous
+ && previous >= item_key
+ {
+ return Err(RadrootsTradeProtocolError::InvalidField(field));
}
previous = Some(item_key);
}
@@ -1093,6 +1098,14 @@ mod tests {
RadrootsTradeId::parse(hex_32('1')).unwrap()
}
+ fn mutation_id(character: char) -> RadrootsTradeMutationId {
+ RadrootsTradeMutationId::parse(hex_64(character)).unwrap()
+ }
+
+ fn candidate_id(character: char) -> RadrootsTradeCandidateId {
+ RadrootsTradeCandidateId::parse(hex_64(character)).unwrap()
+ }
+
fn candidate() -> RadrootsTradeCandidateTermsV1 {
RadrootsTradeCandidateTermsV1 {
candidate_id: None,
@@ -1176,6 +1189,43 @@ mod tests {
}
}
+ fn adjustment(id: &str) -> RadrootsTradeEconomicAdjustmentV1 {
+ RadrootsTradeEconomicAdjustmentV1 {
+ adjustment_id: RadrootsDTag::parse(id).unwrap(),
+ actor: "seller".to_owned(),
+ effect: "charge".to_owned(),
+ amount_mantissa: "10".to_owned(),
+ reason: "packing".to_owned(),
+ }
+ }
+
+ fn reservation_assertion() -> RadrootsSellerReservationAssertionV1 {
+ RadrootsSellerReservationAssertionV1 {
+ reservation_id: RadrootsDTag::parse("reservation-1").unwrap(),
+ inventory_authority_id: pubkey('c'),
+ inventory_epoch: 1,
+ candidate_id: candidate_id('d'),
+ commitments: vec![RadrootsSellerReservationLineV1 {
+ line_id: RadrootsDTag::parse("line-1").unwrap(),
+ bin_id: RadrootsInventoryBinId::parse("bin-1").unwrap(),
+ quantity_mantissa: "2".to_owned(),
+ quantity_scale: 0,
+ unit_code: "count".to_owned(),
+ }],
+ reservation_expires_at_unix_s: 1_800_000_000,
+ assertion_commitment: hex_64('e'),
+ }
+ }
+
+ fn child_envelope(body: RadrootsTradeMutationBodyV1) -> RadrootsTradeMutationEnvelopeV1 {
+ let mut envelope = proposal();
+ envelope.contract_id = body.mutation_kind().contract_id().to_owned();
+ envelope.root_mutation_id = Some(mutation_id('a'));
+ envelope.parent_mutation_ids = vec![mutation_id('a')];
+ envelope.body = body;
+ envelope
+ }
+
#[test]
fn canonical_json_sorts_keys_and_rejects_duplicate_keys() {
assert_eq!(
@@ -1217,4 +1267,517 @@ mod tests {
Err(RadrootsTradeProtocolError::UnsortedParents)
));
}
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_validation_contract_covers_every_mutation_and_parent_rule() {
+ let kinds = [
+ RadrootsTradeMutationKindV1::Proposal,
+ RadrootsTradeMutationKindV1::Decision,
+ RadrootsTradeMutationKindV1::RevisionProposal,
+ RadrootsTradeMutationKindV1::RevisionDecision,
+ RadrootsTradeMutationKindV1::Cancellation,
+ ];
+ for kind in kinds {
+ assert!(!kind.contract_id().is_empty());
+ assert_ne!(kind.nostr_kind(), 0);
+ }
+
+ let mut envelope = proposal();
+ envelope.schema_version += 1;
+ assert!(matches!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::InvalidSchemaVersion { .. })
+ ));
+ let mut envelope = proposal();
+ envelope.contract_id = "wrong".to_owned();
+ assert!(matches!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::ContractMismatch { .. })
+ ));
+ let mut envelope = proposal();
+ envelope.root_mutation_id = Some(mutation_id('a'));
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::InvalidInitialParents)
+ );
+ let mut envelope = proposal();
+ envelope.parent_mutation_ids = vec![mutation_id('a')];
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::InvalidInitialParents)
+ );
+
+ let mut envelope = child_envelope(RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ },
+ });
+ assert!(envelope.validate().is_ok());
+ envelope.root_mutation_id = None;
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::MissingParentMutation)
+ );
+ let mut envelope = child_envelope(RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ },
+ });
+ envelope.parent_mutation_ids.clear();
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::MissingParentMutation)
+ );
+
+ let revision_decision = child_envelope(RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Declined {
+ reason: "inventory unavailable".to_owned(),
+ },
+ });
+ assert!(revision_decision.validate().is_ok());
+ let revision = child_envelope(RadrootsTradeMutationBodyV1::RevisionProposal {
+ candidate: candidate(),
+ });
+ assert!(revision.validate().is_ok());
+
+ for body in [
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id('a')),
+ target_claim_mutation_id: None,
+ reason: "cancelled".to_owned(),
+ },
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(mutation_id('a')),
+ reason: "cancelled".to_owned(),
+ },
+ ] {
+ assert!(child_envelope(body).validate().is_ok());
+ }
+ assert_eq!(
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: None,
+ reason: "cancelled".to_owned(),
+ }
+ .validate(),
+ Err(RadrootsTradeProtocolError::MissingCancellationTarget)
+ );
+ assert!(
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id('a')),
+ target_claim_mutation_id: None,
+ reason: " ".to_owned(),
+ }
+ .validate()
+ .is_err()
+ );
+
+ assert_eq!(
+ validate_parent_mutation_ids(
+ None,
+ &[
+ mutation_id('a'),
+ mutation_id('b'),
+ mutation_id('c'),
+ mutation_id('d'),
+ mutation_id('e'),
+ ],
+ ),
+ Err(RadrootsTradeProtocolError::TooManyParents {
+ max: RADROOTS_TRADE_MAX_PARENT_MUTATIONS,
+ actual: 5,
+ })
+ );
+ assert_eq!(
+ validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('a')]),
+ Err(RadrootsTradeProtocolError::DuplicateParent)
+ );
+ assert_eq!(
+ validate_parent_mutation_ids(Some(&mutation_id('a')), &[mutation_id('a')]),
+ Err(RadrootsTradeProtocolError::SelfParent)
+ );
+ assert!(validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('b')]).is_ok());
+ assert!(validate_sorted_unique_by(&["a", "b"], |value| *value, "values").is_ok());
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_candidate_validation_covers_every_nested_profile() {
+ let base = candidate();
+ assert!(base.validate().is_ok());
+
+ let mut invalid = base.clone();
+ invalid.schema_version += 1;
+ assert!(invalid.validate().is_err());
+ let mut invalid = base.clone();
+ invalid.lines.clear();
+ assert_eq!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::MissingLines)
+ );
+ let mut invalid = base.clone();
+ invalid.lines = vec![base.lines[0].clone(); RADROOTS_TRADE_MAX_ACTIVE_LINES + 1];
+ assert!(matches!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::TooManyLines { .. })
+ ));
+ let mut invalid = base.clone();
+ invalid.lines.push(base.lines[0].clone());
+ assert!(matches!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::InvalidField("lines"))
+ ));
+
+ let tombstone = RadrootsTradeLineTombstoneV1 {
+ line_id: RadrootsDTag::parse("line-2").unwrap(),
+ reason: "removed".to_owned(),
+ };
+ let mut with_tombstone = base.clone();
+ with_tombstone.line_tombstones.push(tombstone.clone());
+ assert!(with_tombstone.validate().is_ok());
+ let mut invalid = with_tombstone.clone();
+ invalid.line_tombstones.push(tombstone);
+ assert!(invalid.validate().is_err());
+ let mut invalid = with_tombstone;
+ invalid.line_tombstones[0].reason = " ".to_owned();
+ assert!(invalid.validate().is_err());
+
+ let mut line = base.lines[0].clone();
+ line.option_id = Some("option-1".to_owned());
+ assert!(line.validate().is_ok());
+ for mutate in [
+ |line: &mut RadrootsTradeCandidateLineV1| {
+ line.listing_snapshot_sha256 = "bad".to_owned()
+ },
+ |line: &mut RadrootsTradeCandidateLineV1| line.product_id = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.option_id = Some(" ".to_owned()),
+ |line: &mut RadrootsTradeCandidateLineV1| line.quantity_mantissa = "1.5".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.unit_code = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.unit_profile = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.unit_price_mantissa = "-".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.currency_code = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.line_subtotal_mantissa = "x".to_owned(),
+ ] {
+ let mut line = base.lines[0].clone();
+ mutate(&mut line);
+ assert!(line.validate().is_err());
+ }
+ let mut line = base.lines[0].clone();
+ line.quantity_mantissa = "-2".to_owned();
+ assert!(line.validate().is_ok());
+
+ let economics = base.economics.clone();
+ for mutate in [
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.profile_id = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.currency_code = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.rounding_profile = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.subtotal_mantissa = "x".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| {
+ value.discount_total_mantissa = "x".to_owned()
+ },
+ |value: &mut RadrootsTradeEconomicsProfileV1| {
+ value.adjustment_total_mantissa = "x".to_owned()
+ },
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.total_mantissa = "x".to_owned(),
+ ] {
+ let mut value = economics.clone();
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+ let mut value = economics.clone();
+ value.adjustments = vec![adjustment("adjustment-1"); RADROOTS_TRADE_MAX_ADJUSTMENTS + 1];
+ assert!(matches!(
+ value.validate(),
+ Err(RadrootsTradeProtocolError::TooManyAdjustments { .. })
+ ));
+ let mut value = economics.clone();
+ value.adjustments = vec![adjustment("adjustment-1"), adjustment("adjustment-1")];
+ assert!(value.validate().is_err());
+ let mut value = economics.clone();
+ value.adjustments = vec![adjustment("adjustment-1")];
+ assert!(value.validate().is_ok());
+ for mutate in [
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.actor = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.effect = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.amount_mantissa = "x".to_owned(),
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.reason = " ".to_owned(),
+ ] {
+ let mut value = adjustment("adjustment-1");
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+
+ let fulfillment = base.fulfillment.clone();
+ for mutate in [
+ |value: &mut RadrootsFulfillmentProfileV1| value.profile_id = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| value.method = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| value.timezone = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| value.location_class = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| {
+ value.ends_at_unix_s = value.starts_at_unix_s
+ },
+ |value: &mut RadrootsFulfillmentProfileV1| value.fold = 2,
+ ] {
+ let mut value = fulfillment.clone();
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+ let mut cancellation = base.cancellation.clone();
+ cancellation.profile_id = " ".to_owned();
+ assert!(cancellation.validate().is_err());
+
+ let private_terms = RadrootsTradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_owned(),
+ schema_id: "schema-1".to_owned(),
+ ciphertext_commitment: hex_64('a'),
+ required_acknowledgement: true,
+ };
+ let mut with_private = base.clone();
+ with_private.private_terms = Some(private_terms.clone());
+ assert!(with_private.validate().is_ok());
+ for mutate in [
+ |value: &mut RadrootsTradePrivateTermsRefV1| value.artifact_id = " ".to_owned(),
+ |value: &mut RadrootsTradePrivateTermsRefV1| value.schema_id = " ".to_owned(),
+ |value: &mut RadrootsTradePrivateTermsRefV1| {
+ value.ciphertext_commitment = "bad".to_owned()
+ },
+ ] {
+ let mut value = private_terms.clone();
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_decision_and_reservation_contracts_cover_all_paths() {
+ let assertion = reservation_assertion();
+ assert!(assertion.validate().is_ok());
+ assert!(
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ }
+ .validate()
+ .is_ok()
+ );
+ assert!(
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(assertion.clone()),
+ }
+ .validate()
+ .is_ok()
+ );
+ assert!(
+ RadrootsTradeDecisionV1::Declined {
+ reason: "declined".to_owned(),
+ }
+ .validate()
+ .is_ok()
+ );
+ assert!(
+ RadrootsTradeDecisionV1::Declined {
+ reason: " ".to_owned(),
+ }
+ .validate()
+ .is_err()
+ );
+
+ let mut invalid = assertion.clone();
+ invalid.commitments.clear();
+ assert_eq!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::MissingReservationCommitments)
+ );
+ let mut invalid = assertion.clone();
+ invalid.commitments.push(invalid.commitments[0].clone());
+ assert!(invalid.validate().is_err());
+ let mut invalid = assertion.clone();
+ invalid.commitments[0].quantity_mantissa = "x".to_owned();
+ assert!(invalid.validate().is_err());
+ let mut invalid = assertion.clone();
+ invalid.commitments[0].unit_code = " ".to_owned();
+ assert!(invalid.validate().is_err());
+ let mut invalid = assertion;
+ invalid.assertion_commitment = "bad".to_owned();
+ assert!(invalid.validate().is_err());
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_canonicalization_and_error_contracts_cover_all_paths() {
+ use serde::de::{Unexpected, value::StrDeserializer};
+
+ let canonical = canonical_trade_mutation_content(proposal()).expect("canonical proposal");
+ assert_eq!(
+ canonical_trade_candidate_id(match &canonical.envelope.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate,
+ _ => unreachable!("proposal"),
+ })
+ .expect("candidate id"),
+ match &canonical.envelope.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.clone().expect("candidate id")
+ }
+ _ => unreachable!("proposal"),
+ }
+ );
+ assert_eq!(
+ canonical_trade_mutation_id(&canonical.envelope).expect("mutation id"),
+ canonical.mutation_id
+ );
+
+ assert!(matches!(
+ trade_mutation_from_canonical_content(
+ &"x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1)
+ ),
+ Err(RadrootsTradeProtocolError::ContentTooLarge { .. })
+ ));
+ assert_eq!(
+ trade_mutation_from_canonical_content(" {} "),
+ Err(RadrootsTradeProtocolError::NonCanonicalJson)
+ );
+ assert!(matches!(
+ trade_mutation_from_canonical_content("{}"),
+ Err(RadrootsTradeProtocolError::InvalidJson(_))
+ ));
+ assert!(matches!(
+ canonical_jcs_from_str("{"),
+ Err(RadrootsTradeProtocolError::InvalidJson(_))
+ ));
+ assert!(matches!(
+ canonical_jcs_from_str("1.5"),
+ Err(RadrootsTradeProtocolError::InvalidJson(_))
+ ));
+ assert_eq!(
+ canonical_jcs_value(&serde_json::json!(1.5)),
+ Err(RadrootsTradeProtocolError::UnsupportedNumber)
+ );
+ assert_eq!(
+ canonical_jcs_value(&Value::Number(Number::from(u64::MAX))).expect("large integer"),
+ u64::MAX.to_string()
+ );
+ assert!(remove_object_field(&mut Value::Null, "id").is_err());
+
+ let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json");
+ value["body"]["candidate"]["candidate_id"] = Value::String(hex_64('f'));
+ let wrong_candidate = canonical_jcs_value(&value).expect("wrong candidate json");
+ assert!(matches!(
+ trade_mutation_from_canonical_content(&wrong_candidate),
+ Err(RadrootsTradeProtocolError::CandidateIdMismatch { .. })
+ ));
+
+ let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json");
+ value["mutation_id"] = Value::String(hex_64('f'));
+ let wrong_mutation = canonical_jcs_value(&value).expect("wrong mutation json");
+ assert!(matches!(
+ trade_mutation_from_canonical_content(&wrong_mutation),
+ Err(RadrootsTradeProtocolError::MutationIdMismatch { .. })
+ ));
+
+ let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json");
+ value["mutation_id"] = Value::Null;
+ value["body"]["candidate"]["candidate_id"] = Value::Null;
+ let undeclared_ids = canonical_jcs_value(&value).expect("undeclared ids json");
+ assert!(trade_mutation_from_canonical_content(&undeclared_ids).is_ok());
+
+ let decision = child_envelope(RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ },
+ });
+ let decision = canonical_trade_mutation_content(decision).expect("canonical decision");
+ assert!(trade_mutation_from_canonical_content(&decision.content).is_ok());
+
+ let revision = child_envelope(RadrootsTradeMutationBodyV1::RevisionProposal {
+ candidate: candidate(),
+ });
+ let revision = canonical_trade_mutation_content(revision).expect("canonical revision");
+ assert!(trade_mutation_from_canonical_content(&revision.content).is_ok());
+
+ let mut oversized = proposal();
+ if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut oversized.body {
+ candidate.lines[0].product_id = "x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1);
+ }
+ assert!(matches!(
+ canonical_trade_mutation_content(oversized),
+ Err(RadrootsTradeProtocolError::ContentTooLarge { .. })
+ ));
+
+ let string_value: Result<NoDuplicateJsonValue, serde_json::Error> =
+ NoDuplicateJsonValueVisitor.visit_string("value".to_owned());
+ assert_eq!(
+ string_value.expect("string").0,
+ Value::String("value".to_owned())
+ );
+ let none_value: Result<NoDuplicateJsonValue, serde_json::Error> =
+ NoDuplicateJsonValueVisitor.visit_none();
+ assert_eq!(none_value.expect("none").0, Value::Null);
+ let some_value: Result<NoDuplicateJsonValue, serde_json::Error> =
+ NoDuplicateJsonValueVisitor.visit_some(StrDeserializer::new("value"));
+ assert_eq!(
+ some_value.expect("some").0,
+ Value::String("value".to_owned())
+ );
+ let expected = <serde_json::Error as serde::de::Error>::invalid_type(
+ Unexpected::Bool(true),
+ &NoDuplicateJsonValueVisitor,
+ );
+ assert!(expected.to_string().contains("JSON value"));
+
+ let parse_error = RadrootsTradeMutationId::parse("bad").expect_err("invalid id");
+ let errors = [
+ RadrootsTradeProtocolError::InvalidSchemaVersion {
+ expected: 1,
+ actual: 2,
+ },
+ RadrootsTradeProtocolError::ContractMismatch {
+ expected: "expected",
+ actual: "actual".to_owned(),
+ },
+ RadrootsTradeProtocolError::InvalidInitialParents,
+ RadrootsTradeProtocolError::MissingParentMutation,
+ RadrootsTradeProtocolError::TooManyParents { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::UnsortedParents,
+ RadrootsTradeProtocolError::DuplicateParent,
+ RadrootsTradeProtocolError::SelfParent,
+ RadrootsTradeProtocolError::MissingLines,
+ RadrootsTradeProtocolError::TooManyLines { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::TooManyAdjustments { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::DuplicateKey("key".to_owned()),
+ RadrootsTradeProtocolError::InvalidJson("json".to_owned()),
+ RadrootsTradeProtocolError::NonCanonicalJson,
+ RadrootsTradeProtocolError::UnsupportedNumber,
+ RadrootsTradeProtocolError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::EmptyField("field"),
+ RadrootsTradeProtocolError::InvalidField("field"),
+ RadrootsTradeProtocolError::InvalidIdentifier {
+ field: "field",
+ error: parse_error,
+ },
+ RadrootsTradeProtocolError::InvalidTimeRange,
+ RadrootsTradeProtocolError::MissingReservationCommitments,
+ RadrootsTradeProtocolError::MissingCancellationTarget,
+ RadrootsTradeProtocolError::CandidateIdMismatch {
+ declared: "a".to_owned(),
+ computed: "b".to_owned(),
+ },
+ RadrootsTradeProtocolError::MutationIdMismatch {
+ declared: "a".to_owned(),
+ computed: "b".to_owned(),
+ },
+ ];
+ for error in errors {
+ assert!(!error.to_string().is_empty());
+ }
+ }
}
diff --git a/crates/event/src/wire.rs b/crates/event/src/wire.rs
@@ -553,12 +553,8 @@ fn validate_extra(
let mut total_json_bytes = 0usize;
let mut extra = BTreeMap::new();
for (key, value) in object {
- let key_json_len = serde_json::to_vec(&key)
- .map_err(|error| RadrootsEventWireError::Json(error.to_string()))?
- .len();
- let value_json_len = serde_json::to_vec(&value)
- .map_err(|error| RadrootsEventWireError::Json(error.to_string()))?
- .len();
+ let key_json_len = serialized_json_string_len(&key);
+ let value_json_len = serialized_json_value_len(&value);
total_json_bytes = total_json_bytes
.saturating_add(key_json_len)
.saturating_add(1)
@@ -574,6 +570,20 @@ fn validate_extra(
Ok(extra)
}
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialized_json_string_len(value: &String) -> usize {
+ serde_json::to_vec(value)
+ .expect("JSON strings always serialize")
+ .len()
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialized_json_value_len(value: &Value) -> usize {
+ serde_json::to_vec(value)
+ .expect("JSON values always serialize")
+ .len()
+}
+
fn push_canonical_json_string(target: &mut String, value: &str) {
target.push('"');
for character in value.chars() {
@@ -944,6 +954,84 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn wire_parser_and_error_contracts_cover_all_typed_failures() {
+ let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id");
+ for error in [
+ RadrootsEventWireError::Json("bad json".to_owned()),
+ RadrootsEventWireError::RootNotObject,
+ RadrootsEventWireError::MissingField("id"),
+ RadrootsEventWireError::InvalidField("kind"),
+ RadrootsEventWireError::InvalidIdentifier {
+ field: "id",
+ error: parse_error.clone(),
+ },
+ RadrootsEventWireError::NonCanonicalIdentifier { field: "id" },
+ RadrootsEventWireError::RawJsonTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::TooManyTags { max: 1, actual: 2 },
+ RadrootsEventWireError::EmptyTag { index: 1 },
+ RadrootsEventWireError::EmptyTagKey { index: 1 },
+ RadrootsEventWireError::ControlCharacterTagKey { index: 1 },
+ RadrootsEventWireError::TagElementTooLarge {
+ tag_index: 1,
+ element_index: 2,
+ max: 3,
+ actual: 4,
+ },
+ RadrootsEventWireError::TagsTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::TooManyExtraFields { max: 1, actual: 2 },
+ RadrootsEventWireError::ExtraJsonTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::from(RadrootsCanonicalEventIdError::InvalidPubkey(
+ parse_error.clone(),
+ )),
+ RadrootsEventWireError::from(RadrootsEventEnvelopeError::NonCanonicalId),
+ RadrootsEventWireError::EventIdMismatch {
+ declared: "a".to_owned(),
+ computed: "b".to_owned(),
+ },
+ ] {
+ assert!(!error.to_string().is_empty());
+ }
+
+ for raw in ["{", "[]", "null"] {
+ assert!(RadrootsNip01EventWire::parse_json(raw).is_err());
+ }
+
+ for (field, replacement) in [
+ ("id", json!(7)),
+ ("id", json!("bad")),
+ ("pubkey", json!(7)),
+ ("pubkey", json!(hex_64('A'))),
+ ("created_at", json!("bad")),
+ ("created_at", json!(-1)),
+ ("kind", json!("bad")),
+ ("kind", json!(u64::from(u32::MAX) + 1)),
+ ("tags", json!("bad")),
+ ("tags", json!(["bad"])),
+ ("tags", json!([["t", 7]])),
+ ("content", json!(7)),
+ ("sig", json!(7)),
+ ("sig", json!("bad")),
+ ("sig", json!(hex_128('B'))),
+ ] {
+ let mut value = valid_event_value("hello", default_tags());
+ value
+ .as_object_mut()
+ .expect("object")
+ .insert(field.to_owned(), replacement);
+ assert!(RadrootsNip01EventWire::parse_json(raw_json(&value).as_str()).is_err());
+ }
+
+ for field in ["pubkey", "created_at", "kind", "tags", "content", "sig"] {
+ let mut value = valid_event_value("hello", default_tags());
+ value.as_object_mut().expect("object").remove(field);
+ assert!(RadrootsNip01EventWire::parse_json(raw_json(&value).as_str()).is_err());
+ }
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn checked_in_conformance_vectors_match_wire_behavior() {
let vectors =
include_str!("../../../contracts/conformance/vectors/event/nip01_wire.v1.json");