commit 3dc0337cb58d58fbccdba67e49fd494fbcea6676
parent 5953aea9812bdfda4d0eae96b38801abb2d1443d
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 06:45:11 +0000
draft: validate frozen event contract parts
- add parts-level event contract shape validation for draft inputs
- reject malformed contract shapes before frozen draft event id computation
- carry contract validation failures through explicit draft errors
- ensure the wire validation lane runs frozen draft coverage
Diffstat:
3 files changed, 248 insertions(+), 54 deletions(-)
diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs
@@ -3183,20 +3183,29 @@ pub fn validate_event_contract_shape(
event: &RadrootsNostrEvent,
contract_id: &str,
) -> Result<(), RadrootsContractValidationError> {
+ validate_event_contract_parts(event.kind, &event.tags, event.content.as_str(), contract_id)
+}
+
+pub fn validate_event_contract_parts(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+ contract_id: &str,
+) -> Result<(), RadrootsContractValidationError> {
let contract = event_contract(contract_id).ok_or_else(|| {
RadrootsContractValidationError::UnknownContract {
contract_id: contract_id.to_owned(),
}
})?;
- if event.kind != contract.kind {
+ if kind != contract.kind {
return Err(RadrootsContractValidationError::KindMismatch {
expected: contract.kind,
- actual: event.kind,
+ actual: kind,
});
}
- validate_content_shape(event, contract)?;
- validate_contract_tags(event, contract)?;
- validate_custom_knowledge_contract(event, contract)?;
+ validate_content_shape_parts(content, contract)?;
+ validate_contract_tags_parts(tags, contract)?;
+ validate_custom_knowledge_contract_parts(content, contract)?;
Ok(())
}
@@ -3260,13 +3269,13 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> {
}
}
-fn validate_content_shape(
- event: &RadrootsNostrEvent,
+fn validate_content_shape_parts(
+ content: &str,
contract: &RadrootsEventContract,
) -> Result<(), RadrootsContractValidationError> {
match contract.content_schema {
RadrootsContentSchema::Empty => {
- if event.content.is_empty() {
+ if content.is_empty() {
Ok(())
} else {
Err(RadrootsContractValidationError::ContentMustBeEmpty {
@@ -3274,17 +3283,17 @@ fn validate_content_shape(
})
}
}
- RadrootsContentSchema::JsonObject => parse_content_object(event, contract.id).map(|_| ()),
+ RadrootsContentSchema::JsonObject => parse_content_object(content, contract.id).map(|_| ()),
_ => Ok(()),
}
}
-fn validate_contract_tags(
- event: &RadrootsNostrEvent,
+fn validate_contract_tags_parts(
+ tags: &[Vec<String>],
contract: &RadrootsEventContract,
) -> Result<(), RadrootsContractValidationError> {
for tag_contract in contract.tags {
- let count = tag_count(&event.tags, tag_contract.name);
+ let count = tag_count(tags, tag_contract.name);
let has_multiple_contracts_for_name = contract
.tags
.iter()
@@ -3325,7 +3334,7 @@ fn validate_contract_tags(
RadrootsTagCardinality::OptionalMany => {}
}
if tag_contract.name == "contract" {
- let actual = tag_value(&event.tags, "contract").map(ToOwned::to_owned);
+ let actual = tag_value(tags, "contract").map(ToOwned::to_owned);
if actual.as_deref() != Some(contract.id) {
return Err(RadrootsContractValidationError::TagValueMismatch {
contract_id: contract.id,
@@ -3335,18 +3344,17 @@ fn validate_contract_tags(
});
}
}
- validate_contract_tag_values(event, contract, tag_contract)?;
+ validate_contract_tag_values(tags, contract, tag_contract)?;
}
Ok(())
}
fn validate_contract_tag_values(
- event: &RadrootsNostrEvent,
+ tags: &[Vec<String>],
contract: &RadrootsEventContract,
tag_contract: &RadrootsTagContract,
) -> Result<(), RadrootsContractValidationError> {
- for tag in event
- .tags
+ for tag in tags
.iter()
.filter(|tag| tag.first().map(|value| value.as_str()) == Some(tag_contract.name))
{
@@ -3456,14 +3464,14 @@ fn tag_value_type_expectation(value_type: RadrootsTagValueType) -> &'static str
}
}
-fn validate_custom_knowledge_contract(
- event: &RadrootsNostrEvent,
+fn validate_custom_knowledge_contract_parts(
+ content: &str,
contract: &RadrootsEventContract,
) -> Result<(), RadrootsContractValidationError> {
let Some(expected_schema) = custom_knowledge_schema(contract.id) else {
return Ok(());
};
- let object = parse_content_object(event, contract.id)?;
+ let object = parse_content_object(content, contract.id)?;
reject_forbidden_knowledge_fields(&object, contract.id)?;
match object.get("schema").and_then(|value| value.as_str()) {
@@ -3501,10 +3509,10 @@ fn validate_custom_knowledge_contract(
}
fn parse_content_object(
- event: &RadrootsNostrEvent,
+ content: &str,
contract_id: &'static str,
) -> Result<serde_json::Map<String, serde_json::Value>, RadrootsContractValidationError> {
- match serde_json::from_str::<serde_json::Value>(&event.content) {
+ match serde_json::from_str::<serde_json::Value>(content) {
Ok(serde_json::Value::Object(object)) => Ok(object),
_ => Err(RadrootsContractValidationError::InvalidJsonContent { contract_id }),
}
@@ -4328,33 +4336,29 @@ mod tests {
let required_many =
synthetic_event_contract("radroots.test.required_many.v1", REQUIRED_MANY_TEST_TAGS);
assert_eq!(
- validate_contract_tags(&unsigned_event(KIND_POST, Vec::new(), ""), &required_many),
+ validate_contract_tags_parts(&[], &required_many),
Err(RadrootsContractValidationError::MissingTag {
contract_id: "radroots.test.required_many.v1",
name: "test_many",
})
);
assert_eq!(
- validate_contract_tags(
- &unsigned_event(KIND_POST, vec![vec!["test_many", "one"]], ""),
- &required_many,
+ validate_contract_tags_parts(
+ &vec![vec!["test_many".to_owned(), "one".to_owned()]],
+ &required_many
),
Ok(())
);
let optional_one =
synthetic_event_contract("radroots.test.optional_one.v1", OPTIONAL_ONE_TEST_TAGS);
+ assert_eq!(validate_contract_tags_parts(&[], &optional_one), Ok(()));
assert_eq!(
- validate_contract_tags(&unsigned_event(KIND_POST, Vec::new(), ""), &optional_one),
- Ok(())
- );
- assert_eq!(
- validate_contract_tags(
- &unsigned_event(
- KIND_POST,
- vec![vec!["test_optional", "one"], vec!["test_optional", "two"],],
- "",
- ),
+ validate_contract_tags_parts(
+ &vec![
+ vec!["test_optional".to_owned(), "one".to_owned()],
+ vec!["test_optional".to_owned(), "two".to_owned()],
+ ],
&optional_one,
),
Err(RadrootsContractValidationError::TagCardinalityMismatch {
@@ -4368,12 +4372,11 @@ mod tests {
DUPLICATE_REQUIRED_TEST_TAGS,
);
assert_eq!(
- validate_contract_tags(
- &unsigned_event(
- KIND_POST,
- vec![vec!["test_required", "one"], vec!["test_required", "two"],],
- "",
- ),
+ validate_contract_tags_parts(
+ &vec![
+ vec!["test_required".to_owned(), "one".to_owned()],
+ vec!["test_required".to_owned(), "two".to_owned()],
+ ],
&duplicate_required,
),
Ok(())
@@ -4384,12 +4387,11 @@ mod tests {
DUPLICATE_OPTIONAL_TEST_TAGS,
);
assert_eq!(
- validate_contract_tags(
- &unsigned_event(
- KIND_POST,
- vec![vec!["test_optional", "one"], vec!["test_optional", "two"],],
- "",
- ),
+ validate_contract_tags_parts(
+ &vec![
+ vec!["test_optional".to_owned(), "one".to_owned()],
+ vec!["test_optional".to_owned(), "two".to_owned()],
+ ],
&duplicate_optional,
),
Ok(())
diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs
@@ -15,7 +15,10 @@ use std::{
};
use crate::RadrootsNostrEvent;
-use crate::contract::{RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, event_contract};
+use crate::contract::{
+ RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsContractValidationError, event_contract,
+ validate_event_contract_parts,
+};
use crate::ids::{
RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey,
};
@@ -30,6 +33,10 @@ pub enum RadrootsDraftError {
expected_kind: u32,
actual_kind: u32,
},
+ ContractShape {
+ contract_id: String,
+ error: RadrootsContractValidationError,
+ },
SignedEventPubkeyMismatch {
expected_pubkey: String,
actual_pubkey: String,
@@ -76,6 +83,11 @@ impl fmt::Display for RadrootsDraftError {
f,
"event contract `{contract_id}` expects kind {expected_kind}, got {actual_kind}"
),
+ Self::ContractShape { contract_id, error } => write!(
+ f,
+ "event contract `{contract_id}` shape validation failed with code {}",
+ error.code()
+ ),
Self::SignedEventPubkeyMismatch {
expected_pubkey,
actual_pubkey,
@@ -182,6 +194,12 @@ impl RadrootsFrozenEventDraft {
}
let expected_pubkey = RadrootsPublicKey::parse(expected_pubkey.as_ref())?.into_string();
let content = content.into();
+ validate_event_contract_parts(kind, &tags, content.as_str(), contract.id).map_err(
+ |error| RadrootsDraftError::ContractShape {
+ contract_id: contract.id.to_owned(),
+ error,
+ },
+ )?;
let expected_event_id =
compute_nip01_event_id(expected_pubkey.as_str(), created_at, kind, &tags, &content)?
.into_string();
@@ -381,7 +399,7 @@ fn push_json_string(target: &mut String, value: &str) -> Result<(), RadrootsDraf
#[cfg(test)]
mod tests {
use super::*;
- use crate::kinds::{KIND_POST, KIND_PROFILE};
+ use crate::kinds::{KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST, KIND_PROFILE};
fn hex_64(character: char) -> String {
core::iter::repeat_n(character, 64).collect()
@@ -413,6 +431,10 @@ mod tests {
.expect("draft")
}
+ fn claim_content() -> &'static str {
+ r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#
+ }
+
#[test]
fn frozen_draft_computes_expected_event_id() {
let draft = RadrootsFrozenEventDraft::new(
@@ -519,6 +541,108 @@ mod tests {
}
#[test]
+ fn draft_constructor_rejects_contract_shape_errors() {
+ let missing_contract = RadrootsFrozenEventDraft::new(
+ "radroots.knowledge.claim.v1",
+ KIND_KNOWLEDGE_CLAIM,
+ 1,
+ Vec::new(),
+ claim_content(),
+ hex_64('a'),
+ )
+ .expect_err("missing contract tag");
+ assert!(matches!(
+ missing_contract,
+ RadrootsDraftError::ContractShape {
+ error: RadrootsContractValidationError::MissingTag {
+ name: "contract",
+ ..
+ },
+ ..
+ }
+ ));
+
+ let invalid_event_pointer = RadrootsFrozenEventDraft::new(
+ "radroots.knowledge.claim.v1",
+ KIND_KNOWLEDGE_CLAIM,
+ 1,
+ vec![
+ vec![
+ "contract".to_owned(),
+ "radroots.knowledge.claim.v1".to_owned(),
+ ],
+ vec![
+ "source".to_owned(),
+ "not-hex".to_owned(),
+ hex_64('a'),
+ KIND_KNOWLEDGE_SOURCE.to_string(),
+ String::new(),
+ ],
+ ],
+ claim_content(),
+ hex_64('a'),
+ )
+ .expect_err("invalid event pointer");
+ assert!(matches!(
+ invalid_event_pointer,
+ RadrootsDraftError::ContractShape {
+ error: RadrootsContractValidationError::TagValueMismatch { name: "source", .. },
+ ..
+ }
+ ));
+
+ let invalid_relay = RadrootsFrozenEventDraft::new(
+ "radroots.knowledge.claim.v1",
+ KIND_KNOWLEDGE_CLAIM,
+ 1,
+ vec![
+ vec![
+ "contract".to_owned(),
+ "radroots.knowledge.claim.v1".to_owned(),
+ ],
+ vec![
+ "source".to_owned(),
+ hex_64('b'),
+ hex_64('a'),
+ KIND_KNOWLEDGE_SOURCE.to_string(),
+ String::new(),
+ "http://relay.radroots.example".to_owned(),
+ ],
+ ],
+ claim_content(),
+ hex_64('a'),
+ )
+ .expect_err("invalid event pointer relay");
+ assert!(matches!(
+ invalid_relay,
+ RadrootsDraftError::ContractShape {
+ error: RadrootsContractValidationError::TagValueMismatch { name: "source", .. },
+ ..
+ }
+ ));
+
+ let invalid_json = RadrootsFrozenEventDraft::new(
+ "radroots.knowledge.claim.v1",
+ KIND_KNOWLEDGE_CLAIM,
+ 1,
+ vec![vec![
+ "contract".to_owned(),
+ "radroots.knowledge.claim.v1".to_owned(),
+ ]],
+ "not-json",
+ hex_64('a'),
+ )
+ .expect_err("invalid json");
+ assert!(matches!(
+ invalid_json,
+ RadrootsDraftError::ContractShape {
+ error: RadrootsContractValidationError::InvalidJsonContent { .. },
+ ..
+ }
+ ));
+ }
+
+ #[test]
fn signed_event_validates_ids_and_roundtrips_with_serde() {
let signed = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
id: hex_64('d'),
@@ -691,6 +815,13 @@ mod tests {
expected_kind: KIND_POST,
actual_kind: KIND_PROFILE,
},
+ RadrootsDraftError::ContractShape {
+ contract_id: "radroots.knowledge.claim.v1".to_owned(),
+ error: RadrootsContractValidationError::MissingTag {
+ contract_id: "radroots.knowledge.claim.v1",
+ name: "contract",
+ },
+ },
RadrootsDraftError::SignedEventPubkeyMismatch {
expected_pubkey: hex_64('a'),
actual_pubkey: hex_64('b'),
diff --git a/crates/events_codec/tests/wire.rs b/crates/events_codec/tests/wire.rs
@@ -1,10 +1,12 @@
-use radroots_events::kinds::KIND_POST;
+use radroots_events::contract::RadrootsContractValidationError;
+use radroots_events::draft::RadrootsDraftError;
+use radroots_events::kinds::{KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST};
use radroots_events_codec::wire::{
WireEventParts, canonicalize_tags, empty_content, to_frozen_draft,
};
#[test]
-fn canonicalize_tags_trims_sorts_and_dedups() {
+fn wire_canonicalize_tags_trims_sorts_and_dedups() {
let mut tags = vec![
vec![" z ".to_string(), "b".to_string()],
vec!["t".to_string(), "a".to_string()],
@@ -25,7 +27,7 @@ fn canonicalize_tags_trims_sorts_and_dedups() {
}
#[test]
-fn to_frozen_draft_copies_fields_and_computes_expected_id() {
+fn wire_to_frozen_draft_copies_fields_and_computes_expected_id() {
let parts = WireEventParts {
kind: KIND_POST,
content: "hello".to_string(),
@@ -44,7 +46,66 @@ fn to_frozen_draft_copies_fields_and_computes_expected_id() {
}
#[test]
-fn empty_content_is_empty_string() {
+fn wire_to_frozen_draft_rejects_contract_shape_errors() {
+ let missing_contract_tag = WireEventParts {
+ kind: KIND_KNOWLEDGE_CLAIM,
+ content: r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#.to_string(),
+ tags: Vec::new(),
+ };
+ let error = to_frozen_draft(
+ missing_contract_tag,
+ "radroots.knowledge.claim.v1",
+ "a".repeat(64),
+ 99,
+ )
+ .expect_err("missing contract tag");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::ContractShape {
+ error: RadrootsContractValidationError::MissingTag {
+ name: "contract",
+ ..
+ },
+ ..
+ }
+ ));
+
+ let invalid_relay = WireEventParts {
+ kind: KIND_KNOWLEDGE_CLAIM,
+ content: r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#.to_string(),
+ tags: vec![
+ vec![
+ "contract".to_string(),
+ "radroots.knowledge.claim.v1".to_string(),
+ ],
+ vec![
+ "source".to_string(),
+ "b".repeat(64),
+ "a".repeat(64),
+ KIND_KNOWLEDGE_SOURCE.to_string(),
+ String::new(),
+ "http://relay.radroots.example".to_string(),
+ ],
+ ],
+ };
+ let error = to_frozen_draft(
+ invalid_relay,
+ "radroots.knowledge.claim.v1",
+ "a".repeat(64),
+ 99,
+ )
+ .expect_err("invalid relay");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::ContractShape {
+ error: RadrootsContractValidationError::TagValueMismatch { name: "source", .. },
+ ..
+ }
+ ));
+}
+
+#[test]
+fn wire_empty_content_is_empty_string() {
let content = empty_content();
assert!(content.is_empty());
}