commit db0a8bef6b95befe80ac61d39179473077076fff
parent 79a1e89995f8c397002f64d1d706714019211d1a
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 01:29:34 +0000
release: harden knowledge final gates
Diffstat:
61 files changed, 3785 insertions(+), 157 deletions(-)
diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json
@@ -56,6 +56,100 @@
"error_class": "decode_error",
"stage": "contract_validation"
}
+ },
+ {
+ "id": "knowledge_nip54_wiki_article_fork_defer_valid_004",
+ "kind": "knowledge.nip54.wiki_article_tags.valid",
+ "input": {
+ "fixture": "wiki_article_valid"
+ },
+ "expected": {
+ "fork_address_tag": [
+ "a",
+ "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health",
+ "wss://relay.example.com",
+ "fork"
+ ],
+ "fork_event_tag": [
+ "e",
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "wss://relay.example.com",
+ "fork"
+ ],
+ "defer_address_tag": [
+ "a",
+ "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health-v2",
+ "wss://relay.example.com",
+ "defer"
+ ],
+ "defer_event_tag": [
+ "e",
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "wss://relay.example.com",
+ "defer"
+ ]
+ }
+ },
+ {
+ "id": "knowledge_nip54_redirect_valid_005",
+ "kind": "knowledge.nip54.wiki_redirect_tags.valid",
+ "input": {
+ "fixture": "wiki_redirect_valid"
+ },
+ "expected": {
+ "target_tag": [
+ "a",
+ "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health",
+ "wss://relay.example.com"
+ ]
+ }
+ },
+ {
+ "id": "knowledge_nip54_merge_request_valid_006",
+ "kind": "knowledge.nip54.wiki_merge_request.valid",
+ "input": {
+ "fixture": "wiki_merge_request_valid"
+ },
+ "expected": {
+ "content": "Merge synthetic soil article updates",
+ "target_tag": [
+ "a",
+ "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health",
+ "wss://relay.example.com"
+ ],
+ "destination_tag": [
+ "p",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ ],
+ "base_event_tag": [
+ "e",
+ "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ ""
+ ],
+ "source_event_tag": [
+ "e",
+ "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "",
+ "source"
+ ]
+ }
+ },
+ {
+ "id": "knowledge_nip54_merge_request_without_base_valid_007",
+ "kind": "knowledge.nip54.wiki_merge_request.valid",
+ "input": {
+ "fixture": "wiki_merge_request_without_base_valid"
+ },
+ "expected": {
+ "content": "Merge synthetic soil article updates",
+ "base_version_event_id": null,
+ "source_event_tag": [
+ "e",
+ "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "",
+ "source"
+ ]
+ }
}
]
}
diff --git a/contracts/conformance/vectors/knowledge/regenproto_compatibility.v1.json b/contracts/conformance/vectors/knowledge/regenproto_compatibility.v1.json
@@ -56,6 +56,18 @@
"contract_id": "radroots.wiki.article.v1",
"operation": "build_validate_decode_expose"
}
+ },
+ {
+ "id": "knowledge_claim_sdk_builder_downstream_valid_006",
+ "kind": "knowledge.sdk_builder_event.valid",
+ "input": {
+ "sdk": "radroots_sdk",
+ "builder": "RadrootsKnowledgeClaimBuilder"
+ },
+ "expected": {
+ "contract_id": "radroots.knowledge.claim.v1",
+ "operation": "build_event_validate_decode_expose"
+ }
}
]
}
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Core value model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_core"
readme = "README"
[features]
diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Domain event model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_events"
readme = "README"
[features]
diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs
@@ -3154,8 +3154,10 @@ pub fn identify_event_contract(
pub fn validate_event_contract(
event: &RadrootsNostrEvent,
) -> Result<&'static RadrootsEventContract, RadrootsContractValidationError> {
- let contract = identify_event_contract(event.kind, &event.tags, &event.content)
- .map_err(|error| RadrootsContractValidationError::ContractMatch { error })?;
+ let contract = match identify_event_contract(event.kind, &event.tags, &event.content) {
+ Ok(contract) => contract,
+ Err(error) => return Err(RadrootsContractValidationError::ContractMatch { error }),
+ };
validate_event_contract_shape(event, contract.id)?;
Ok(contract)
}
@@ -3502,6 +3504,87 @@ mod tests {
),
];
+ static REQUIRED_MANY_TEST_TAGS: &[RadrootsTagContract] = &[tag(
+ "test_many",
+ RadrootsTagCardinality::RequiredMany,
+ RadrootsTagSemantic::Topic,
+ RadrootsTagValueType::Text,
+ false,
+ )];
+
+ static OPTIONAL_ONE_TEST_TAGS: &[RadrootsTagContract] = &[tag(
+ "test_optional",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Topic,
+ RadrootsTagValueType::Text,
+ false,
+ )];
+
+ static DUPLICATE_REQUIRED_TEST_TAGS: &[RadrootsTagContract] = &[
+ tag(
+ "test_required",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Topic,
+ RadrootsTagValueType::Text,
+ false,
+ ),
+ tag(
+ "test_required",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Category,
+ RadrootsTagValueType::Text,
+ false,
+ ),
+ ];
+
+ static DUPLICATE_OPTIONAL_TEST_TAGS: &[RadrootsTagContract] = &[
+ tag(
+ "test_optional",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Topic,
+ RadrootsTagValueType::Text,
+ false,
+ ),
+ tag(
+ "test_optional",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Category,
+ RadrootsTagValueType::Text,
+ false,
+ ),
+ ];
+
+ fn synthetic_event_contract(
+ id: &'static str,
+ tags: &'static [RadrootsTagContract],
+ ) -> RadrootsEventContract {
+ RadrootsEventContract {
+ id,
+ kind: KIND_POST,
+ name: "Test",
+ payload_type: "Test",
+ class: RadrootsEventClass::Regular,
+ stability: RadrootsEventStability::Experimental,
+ privacy: RadrootsEventPrivacy::Public,
+ author_role: RadrootsActorRole::Any,
+ content_schema: RadrootsContentSchema::PlainText,
+ discriminator: RadrootsEventDiscriminator::KindOnly,
+ tags,
+ reducers: SOCIAL_REDUCERS,
+ }
+ }
+
+ fn synthetic_kind_contract(kind: u32) -> RadrootsKindContract {
+ RadrootsKindContract {
+ kind,
+ canonical_constant: "KIND_TEST",
+ name: "Test",
+ class: RadrootsEventClass::Regular,
+ standard: RadrootsNostrStandard::Radroots,
+ accepted_event_contracts: &[],
+ }
+ }
+
fn unsigned_event(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsNostrEvent {
RadrootsNostrEvent {
id: "0".repeat(64),
@@ -3652,6 +3735,99 @@ mod tests {
}
#[test]
+ fn contract_family_helpers_cover_prefixes_and_kind_branches() {
+ for (id, family) in [
+ (
+ "radroots.account.test.v1",
+ Some(RadrootsContractFamily::Account),
+ ),
+ (
+ "radroots.application.test.v1",
+ Some(RadrootsContractFamily::Application),
+ ),
+ (
+ "radroots.calendar.test.v1",
+ Some(RadrootsContractFamily::Calendar),
+ ),
+ ("radroots.farm.test.v1", Some(RadrootsContractFamily::Farm)),
+ (
+ "radroots.group.test.v1",
+ Some(RadrootsContractFamily::Group),
+ ),
+ ("radroots.http.test.v1", Some(RadrootsContractFamily::Http)),
+ ("radroots.job.test.v1", Some(RadrootsContractFamily::Job)),
+ (
+ "radroots.knowledge.test.v1",
+ Some(RadrootsContractFamily::Knowledge),
+ ),
+ (
+ "radroots.wiki.test.v1",
+ Some(RadrootsContractFamily::Knowledge),
+ ),
+ ("radroots.list.test.v1", Some(RadrootsContractFamily::List)),
+ (
+ "radroots.list_set.test.v1",
+ Some(RadrootsContractFamily::List),
+ ),
+ (
+ "radroots.listing.test.v1",
+ Some(RadrootsContractFamily::Market),
+ ),
+ (
+ "radroots.message.test.v1",
+ Some(RadrootsContractFamily::Message),
+ ),
+ (
+ "radroots.profile.test.v1",
+ Some(RadrootsContractFamily::Profile),
+ ),
+ (
+ "radroots.relay.test.v1",
+ Some(RadrootsContractFamily::Relay),
+ ),
+ (
+ "radroots.trade.test.v1",
+ Some(RadrootsContractFamily::Trade),
+ ),
+ (
+ "radroots.order.test.v1",
+ Some(RadrootsContractFamily::Trade),
+ ),
+ ("radroots.test.unknown.v1", None),
+ ] {
+ assert_eq!(contract_family_for_id(id), family, "{id}");
+ }
+
+ for (kind, family) in [
+ (KIND_PROFILE, RadrootsContractFamily::Profile),
+ (KIND_MESSAGE, RadrootsContractFamily::Message),
+ (KIND_POST, RadrootsContractFamily::Social),
+ (KIND_RELAY_AUTH, RadrootsContractFamily::Relay),
+ (KIND_GROUP_ROLES, RadrootsContractFamily::Group),
+ (KIND_LIST_SET_GENERIC, RadrootsContractFamily::List),
+ (KIND_CALENDAR_EVENT_RSVP, RadrootsContractFamily::Calendar),
+ (KIND_FARM_CRDT_CHANGE, RadrootsContractFamily::Farm),
+ (KIND_LISTING, RadrootsContractFamily::Market),
+ (KIND_ORDER_CANCELLATION, RadrootsContractFamily::Trade),
+ (KIND_KNOWLEDGE_CLAIM, RadrootsContractFamily::Knowledge),
+ (KIND_JOB_FEEDBACK, RadrootsContractFamily::Job),
+ (KIND_JOB_REQUEST_MIN, RadrootsContractFamily::Job),
+ (KIND_JOB_RESULT_MIN, RadrootsContractFamily::Job),
+ ] {
+ assert_eq!(
+ kind_contract_family(&synthetic_kind_contract(kind)),
+ Some(family),
+ "{kind}"
+ );
+ }
+
+ assert_eq!(
+ kind_contract_family(&synthetic_kind_contract(999_999)),
+ None
+ );
+ }
+
+ #[test]
fn exposes_knowledge_contracts() {
let wiki_article = event_contract("radroots.wiki.article.v1").expect("wiki article");
assert_eq!(wiki_article.kind, KIND_WIKI_ARTICLE);
@@ -3699,10 +3875,31 @@ mod tests {
event_contract_family(contract),
Some(RadrootsContractFamily::Knowledge)
);
- assert!(contract.tags.iter().any(|tag| tag.name == "contract"
- && tag.semantic == RadrootsTagSemantic::Contract
- && tag.value_type == RadrootsTagValueType::ContractId));
+ let contract_tag = contract
+ .tags
+ .iter()
+ .find(|tag| tag.name == "contract")
+ .expect("contract tag");
+ assert_eq!(contract_tag.semantic, RadrootsTagSemantic::Contract);
+ assert_eq!(contract_tag.value_type, RadrootsTagValueType::ContractId);
+ }
+ }
+
+ #[test]
+ fn custom_knowledge_schema_lookup_covers_registered_ids() {
+ for id in [
+ "radroots.knowledge.source.v1",
+ "radroots.knowledge.evidence_bounty.v1",
+ "radroots.knowledge.claim.v1",
+ "radroots.knowledge.relation.v1",
+ "radroots.knowledge.review.v1",
+ "radroots.knowledge.field_report.v1",
+ "radroots.knowledge.change_proposal.v1",
+ "radroots.knowledge.contribution_attestation.v1",
+ ] {
+ assert_eq!(custom_knowledge_schema(id), Some(id), "{id}");
}
+ assert_eq!(custom_knowledge_schema("radroots.wiki.article.v1"), None);
}
#[test]
@@ -3730,6 +3927,12 @@ mod tests {
identify_event_contract(999_999, &[], "{}"),
Err(RadrootsContractMatchError::UnsupportedKind(999_999))
);
+ assert_eq!(
+ validate_event_contract(&unsigned_event(999_999, Vec::new(), "{}")),
+ Err(RadrootsContractValidationError::ContractMatch {
+ error: RadrootsContractMatchError::UnsupportedKind(999_999),
+ })
+ );
let tags = vec![vec!["d".to_owned(), "unknown".to_owned()]];
assert_eq!(
@@ -3880,6 +4083,176 @@ mod tests {
}
#[test]
+ fn validate_event_contract_shape_reports_registry_kind_and_content_errors() {
+ let event = unsigned_event(KIND_POST, Vec::new(), "hello");
+ assert_eq!(
+ validate_event_contract_shape(&event, "missing.contract.v1"),
+ Err(RadrootsContractValidationError::UnknownContract {
+ contract_id: "missing.contract.v1".to_owned(),
+ })
+ );
+ assert_eq!(
+ validate_event_contract_shape(&event, "radroots.profile.metadata.v1"),
+ Err(RadrootsContractValidationError::KindMismatch {
+ expected: KIND_PROFILE,
+ actual: KIND_POST,
+ })
+ );
+
+ let invalid_json = unsigned_event(
+ KIND_KNOWLEDGE_CLAIM,
+ vec![vec!["contract", "radroots.knowledge.claim.v1"]],
+ "not-json",
+ );
+ assert_eq!(
+ validate_event_contract_shape(&invalid_json, "radroots.knowledge.claim.v1"),
+ Err(RadrootsContractValidationError::InvalidJsonContent {
+ contract_id: "radroots.knowledge.claim.v1",
+ })
+ );
+
+ assert_eq!(
+ validate_event_contract_shape(
+ &unsigned_event(KIND_POST, Vec::new(), "plain text"),
+ "radroots.social.post.v1",
+ ),
+ Ok(())
+ );
+ }
+
+ #[test]
+ fn validate_contract_tags_reports_cardinality_errors() {
+ let missing_required_one = unsigned_event(
+ KIND_KNOWLEDGE_CLAIM,
+ Vec::new(),
+ r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#,
+ );
+ assert_eq!(
+ validate_event_contract_shape(&missing_required_one, "radroots.knowledge.claim.v1"),
+ Err(RadrootsContractValidationError::MissingTag {
+ contract_id: "radroots.knowledge.claim.v1",
+ name: "contract",
+ })
+ );
+
+ let duplicate_required_one = unsigned_event(
+ KIND_KNOWLEDGE_CLAIM,
+ vec![
+ vec!["contract", "radroots.knowledge.claim.v1"],
+ vec!["contract", "radroots.knowledge.claim.v1"],
+ ],
+ r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#,
+ );
+ assert_eq!(
+ validate_event_contract_shape(&duplicate_required_one, "radroots.knowledge.claim.v1"),
+ Err(RadrootsContractValidationError::TagCardinalityMismatch {
+ contract_id: "radroots.knowledge.claim.v1",
+ name: "contract",
+ })
+ );
+
+ 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),
+ 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,
+ ),
+ Ok(())
+ );
+
+ let optional_one =
+ synthetic_event_contract("radroots.test.optional_one.v1", OPTIONAL_ONE_TEST_TAGS);
+ 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"],],
+ "",
+ ),
+ &optional_one,
+ ),
+ Err(RadrootsContractValidationError::TagCardinalityMismatch {
+ contract_id: "radroots.test.optional_one.v1",
+ name: "test_optional",
+ })
+ );
+
+ let duplicate_required = synthetic_event_contract(
+ "radroots.test.duplicate_required.v1",
+ DUPLICATE_REQUIRED_TEST_TAGS,
+ );
+ assert_eq!(
+ validate_contract_tags(
+ &unsigned_event(
+ KIND_POST,
+ vec![vec!["test_required", "one"], vec!["test_required", "two"],],
+ "",
+ ),
+ &duplicate_required,
+ ),
+ Ok(())
+ );
+
+ let duplicate_optional = synthetic_event_contract(
+ "radroots.test.duplicate_optional.v1",
+ DUPLICATE_OPTIONAL_TEST_TAGS,
+ );
+ assert_eq!(
+ validate_contract_tags(
+ &unsigned_event(
+ KIND_POST,
+ vec![vec!["test_optional", "one"], vec!["test_optional", "two"],],
+ "",
+ ),
+ &duplicate_optional,
+ ),
+ Ok(())
+ );
+ }
+
+ #[test]
+ fn validate_custom_knowledge_contract_rejects_missing_schema_and_bad_version() {
+ let missing_schema = unsigned_event(
+ KIND_KNOWLEDGE_CLAIM,
+ vec![vec!["contract", "radroots.knowledge.claim.v1"]],
+ r#"{"schema_version":1}"#,
+ );
+ assert_eq!(
+ validate_event_contract_shape(&missing_schema, "radroots.knowledge.claim.v1"),
+ Err(RadrootsContractValidationError::MissingContentField {
+ contract_id: "radroots.knowledge.claim.v1",
+ field: "schema",
+ })
+ );
+
+ let bad_version = unsigned_event(
+ KIND_KNOWLEDGE_CLAIM,
+ vec![vec!["contract", "radroots.knowledge.claim.v1"]],
+ r#"{"schema":"radroots.knowledge.claim.v1","schema_version":2}"#,
+ );
+ assert_eq!(
+ validate_event_contract_shape(&bad_version, "radroots.knowledge.claim.v1"),
+ Err(RadrootsContractValidationError::ContentFieldMismatch {
+ contract_id: "radroots.knowledge.claim.v1",
+ field: "schema_version",
+ expected: "1".to_owned(),
+ })
+ );
+ }
+
+ #[test]
fn validates_nip54_empty_redirect_content() {
let event = unsigned_event(
KIND_WIKI_REDIRECT,
@@ -3907,12 +4280,86 @@ mod tests {
#[test]
fn exposes_validation_error_codes() {
- let error = RadrootsContractValidationError::MissingTag {
- contract_id: "radroots.knowledge.claim.v1",
- name: "contract",
- };
-
- assert_eq!(error.code(), "missing_tag");
+ for (error, code) in [
+ (
+ RadrootsContractValidationError::UnknownContract {
+ contract_id: "missing".to_owned(),
+ },
+ "unknown_contract",
+ ),
+ (
+ RadrootsContractValidationError::ContractMatch {
+ error: RadrootsContractMatchError::UnsupportedKind(999_999),
+ },
+ "contract_match",
+ ),
+ (
+ RadrootsContractValidationError::KindMismatch {
+ expected: KIND_PROFILE,
+ actual: KIND_POST,
+ },
+ "kind_mismatch",
+ ),
+ (
+ RadrootsContractValidationError::ContentMustBeEmpty {
+ contract_id: "radroots.wiki.redirect.v1",
+ },
+ "content_must_be_empty",
+ ),
+ (
+ RadrootsContractValidationError::InvalidJsonContent {
+ contract_id: "radroots.knowledge.claim.v1",
+ },
+ "invalid_json_content",
+ ),
+ (
+ RadrootsContractValidationError::MissingTag {
+ contract_id: "radroots.knowledge.claim.v1",
+ name: "contract",
+ },
+ "missing_tag",
+ ),
+ (
+ RadrootsContractValidationError::TagCardinalityMismatch {
+ contract_id: "radroots.knowledge.claim.v1",
+ name: "contract",
+ },
+ "tag_cardinality_mismatch",
+ ),
+ (
+ RadrootsContractValidationError::TagValueMismatch {
+ contract_id: "radroots.knowledge.claim.v1",
+ name: "contract",
+ expected: "radroots.knowledge.claim.v1".to_owned(),
+ actual: None,
+ },
+ "tag_value_mismatch",
+ ),
+ (
+ RadrootsContractValidationError::MissingContentField {
+ contract_id: "radroots.knowledge.claim.v1",
+ field: "schema",
+ },
+ "missing_content_field",
+ ),
+ (
+ RadrootsContractValidationError::ContentFieldMismatch {
+ contract_id: "radroots.knowledge.claim.v1",
+ field: "schema",
+ expected: "radroots.knowledge.claim.v1".to_owned(),
+ },
+ "content_field_mismatch",
+ ),
+ (
+ RadrootsContractValidationError::ForbiddenContentField {
+ contract_id: "radroots.knowledge.claim.v1",
+ field: "review_status",
+ },
+ "forbidden_content_field",
+ ),
+ ] {
+ assert_eq!(error.code(), code);
+ }
}
#[test]
diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs
@@ -169,8 +169,10 @@ impl RadrootsFrozenEventDraft {
expected_pubkey: impl AsRef<str>,
) -> Result<Self, RadrootsDraftError> {
let contract_id = contract_id.into();
- let contract = event_contract(&contract_id)
- .ok_or_else(|| RadrootsDraftError::UnknownContract(contract_id.clone()))?;
+ let contract = match event_contract(&contract_id) {
+ Some(contract) => contract,
+ None => return Err(RadrootsDraftError::UnknownContract(contract_id.clone())),
+ };
if contract.kind != kind {
return Err(RadrootsDraftError::ContractKindMismatch {
contract_id,
@@ -662,6 +664,14 @@ mod tests {
));
let mut draft = post_draft();
+ draft.expected_pubkey = "not-hex".to_owned();
+ let mut signed = signed_event_for_draft(&post_draft());
+ signed.pubkey = "not-hex".to_owned();
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("id parse");
+ assert!(matches!(error, RadrootsDraftError::IdParse(_)));
+
+ let mut draft = post_draft();
draft.expected_event_id = hex_64('f');
let signed = signed_event_for_draft(&draft);
let error =
diff --git a/crates/events/src/event_head.rs b/crates/events/src/event_head.rs
@@ -96,32 +96,30 @@ pub fn event_head_candidate_for_class(
);
}
};
- let coordinate = match class {
- RadrootsEventClass::Replaceable => RadrootsEventHeadCoordinate::Replaceable {
+ let coordinate = if class == RadrootsEventClass::Replaceable {
+ RadrootsEventHeadCoordinate::Replaceable {
kind: event.kind,
pubkey,
- },
- RadrootsEventClass::Addressable => {
- let Some(d_tag) = first_tag_value(&event.tags, TAG_D) else {
+ }
+ } else {
+ let Some(d_tag) = first_tag_value(&event.tags, TAG_D) else {
+ return RadrootsEventHeadCandidateResult::Malformed(
+ RadrootsEventHeadMalformed::MissingDTag,
+ );
+ };
+ let d_tag = match RadrootsDTag::parse(d_tag) {
+ Ok(d_tag) => d_tag,
+ Err(error) => {
return RadrootsEventHeadCandidateResult::Malformed(
- RadrootsEventHeadMalformed::MissingDTag,
+ RadrootsEventHeadMalformed::InvalidDTag(error),
);
- };
- let d_tag = match RadrootsDTag::parse(d_tag) {
- Ok(d_tag) => d_tag,
- Err(error) => {
- return RadrootsEventHeadCandidateResult::Malformed(
- RadrootsEventHeadMalformed::InvalidDTag(error),
- );
- }
- };
- RadrootsEventHeadCoordinate::Addressable {
- kind: event.kind,
- pubkey,
- d_tag,
}
+ };
+ RadrootsEventHeadCoordinate::Addressable {
+ kind: event.kind,
+ pubkey,
+ d_tag,
}
- RadrootsEventClass::Regular | RadrootsEventClass::Ephemeral => unreachable!(),
};
RadrootsEventHeadCandidateResult::Candidate(RadrootsEventHeadCandidate {
coordinate,
@@ -487,4 +485,13 @@ mod tests {
RadrootsEventHeadCandidateResult::NotHeadSelected
);
}
+
+ #[test]
+ fn expect_candidate_reports_non_candidate_inputs() {
+ let result = std::panic::catch_unwind(|| {
+ expect_candidate(RadrootsEventHeadCandidateResult::NotHeadSelected);
+ });
+
+ assert!(result.is_err());
+ }
}
diff --git a/crates/events/src/ids.rs b/crates/events/src/ids.rs
@@ -615,4 +615,68 @@ mod tests {
format!("\"{}\"", hex_64('e'))
);
}
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn serde_missing_fields_exercise_identifier_deserializers() {
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingPublicKey {
+ value: RadrootsPublicKey,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingEventId {
+ value: RadrootsEventId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingDTag {
+ value: RadrootsDTag,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingListingAddress {
+ value: RadrootsListingAddress,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingOrderId {
+ value: RadrootsOrderId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingOrderRevisionId {
+ value: RadrootsOrderRevisionId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingOrderQuoteId {
+ value: RadrootsOrderQuoteId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingInventoryBinId {
+ value: RadrootsInventoryBinId,
+ }
+
+ fn missing_field_message<T>() -> String
+ where
+ T: serde::de::DeserializeOwned + core::fmt::Debug,
+ {
+ serde_json::from_str::<T>("{}")
+ .expect_err("missing field")
+ .to_string()
+ }
+
+ 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::<MissingDTag>(), missing);
+ assert_eq!(missing_field_message::<MissingListingAddress>(), missing);
+ assert_eq!(missing_field_message::<MissingOrderId>(), missing);
+ assert_eq!(missing_field_message::<MissingOrderRevisionId>(), missing);
+ assert_eq!(missing_field_message::<MissingOrderQuoteId>(), missing);
+ assert_eq!(missing_field_message::<MissingInventoryBinId>(), missing);
+ }
}
diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs
@@ -819,7 +819,10 @@ mod tests {
assert_eq!(KIND_KNOWLEDGE_CHANGE_PROPOSAL, 3464);
assert_eq!(KIND_CONTRIBUTION_ATTESTATION, 3465);
+ assert!(is_wiki_event_kind(KIND_WIKI_MERGE_REQUEST));
assert!(is_wiki_event_kind(KIND_WIKI_ARTICLE));
+ assert!(is_wiki_event_kind(KIND_WIKI_REDIRECT));
+ assert!(!is_wiki_event_kind(KIND_KNOWLEDGE_CLAIM));
assert!(is_knowledge_event_kind(KIND_WIKI_ARTICLE));
assert!(is_knowledge_event_kind(KIND_KNOWLEDGE_CLAIM));
assert!(is_knowledge_mvp_event_kind(KIND_KNOWLEDGE_FIELD_REPORT));
diff --git a/crates/events/src/location.rs b/crates/events/src/location.rs
@@ -28,3 +28,41 @@ fn has_public_location_text(value: &str) -> bool {
let value = value.trim();
!value.is_empty() && !value.eq_ignore_ascii_case("null")
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn public_geohash5_accepts_base32_prefix_and_trims_input() {
+ assert!(is_public_geohash5(" 9q8yy "));
+ assert!(is_public_geohash5("9Q8YY"));
+ assert!(!is_public_geohash5("9q8y"));
+ assert!(!is_public_geohash5("9q8yyz"));
+ assert!(!is_public_geohash5("9q8ya"));
+ }
+
+ #[test]
+ fn textual_locality_requires_primary_and_one_public_component() {
+ assert!(has_textual_locality(
+ "Market shed",
+ Some("Asheville"),
+ None,
+ None
+ ));
+ assert!(has_textual_locality(
+ "Market shed",
+ Some("null"),
+ Some("NC"),
+ None
+ ));
+ assert!(!has_textual_locality("", Some("Asheville"), None, None));
+ assert!(!has_textual_locality("Market shed", None, None, None));
+ assert!(!has_textual_locality(
+ "Market shed",
+ Some("null"),
+ Some(" "),
+ None
+ ));
+ }
+}
diff --git a/crates/events/src/trade_validation.rs b/crates/events/src/trade_validation.rs
@@ -102,3 +102,28 @@ pub struct RadrootsTradeValidationListingResult {
pub valid: bool,
pub errors: Vec<RadrootsTradeValidationListingError>,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn listing_validation_error_display_covers_location_variants() {
+ assert_eq!(
+ RadrootsTradeValidationListingError::MissingLocation.to_string(),
+ "missing listing location"
+ );
+ assert_eq!(
+ RadrootsTradeValidationListingError::MissingLocationLocality.to_string(),
+ "missing listing location locality"
+ );
+ assert_eq!(
+ RadrootsTradeValidationListingError::MissingLocationGeohash.to_string(),
+ "missing listing location geohash"
+ );
+ assert_eq!(
+ RadrootsTradeValidationListingError::InvalidLocationGeohash.to_string(),
+ "invalid listing location geohash"
+ );
+ }
+}
diff --git a/crates/events_codec/Cargo.toml b/crates/events_codec/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Event codec layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_events_codec"
readme = "README"
[features]
diff --git a/crates/events_codec/src/farm/mod.rs b/crates/events_codec/src/farm/mod.rs
@@ -4,9 +4,9 @@ pub mod list_sets;
#[cfg(test)]
mod tests {
- use crate::error::EventEncodeError;
+ use crate::error::{EventEncodeError, EventParseError};
#[cfg(feature = "serde_json")]
- use crate::farm::decode::farm_from_event;
+ use crate::farm::decode::{farm_from_event, parsed_from_event};
use crate::farm::encode::{farm_build_tags, farm_ref_tags};
use crate::farm::list_sets::{
farm_listings_list_set_from_listings, farm_members_list_set,
@@ -228,6 +228,170 @@ mod tests {
err,
EventEncodeError::EmptyRequiredField("farm.d_tag")
));
+
+ let mut farm = RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ name: "Test Farm".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsFarmPublicLocation {
+ primary: " ".to_string(),
+ city: Some("null".to_string()),
+ region: None,
+ country: None,
+ geohash: "9q8yy".to_string(),
+ }),
+ tags: None,
+ };
+ let err = farm_build_tags(&farm).expect_err("expected missing locality");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.locality")
+ ));
+
+ let location = farm.location.as_mut().expect("location");
+ location.primary = "Test Farm".to_string();
+ location.city = Some("Santa Cruz".to_string());
+ let tags = farm_build_tags(&farm).expect("valid location after locality repair");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
+ );
+ }
+
+ #[test]
+ #[cfg(feature = "serde_json")]
+ fn farm_decode_rejects_private_location_and_ops_shapes() {
+ let farm = RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ name: "Test Farm".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsFarmPublicLocation {
+ primary: "Test Farm".to_string(),
+ city: Some("Santa Cruz".to_string()),
+ region: Some("California".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
+ }),
+ tags: None,
+ };
+ let content = serde_json::to_string(&farm).expect("farm content");
+ let tags = vec![
+ Vec::new(),
+ vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()],
+ vec!["g".to_string(), "9q8yy".to_string()],
+ ];
+ let parsed = parsed_from_event(
+ "event-id".to_string(),
+ "author".to_string(),
+ 42,
+ KIND_FARM,
+ content.clone(),
+ tags,
+ "sig".to_string(),
+ )
+ .expect("parsed farm");
+ assert_eq!(parsed.event.sig, "sig");
+ assert_eq!(parsed.data.data.name, "Test Farm");
+
+ for (tag, expected) in [
+ (vec!["g".to_string()], "g"),
+ (vec!["g".to_string(), "9q8ya".to_string()], "g"),
+ (vec!["dd".to_string(), "secret".to_string()], "dd"),
+ (vec!["dd.lat".to_string(), "1".to_string()], "dd.lat"),
+ (vec!["dd.lon".to_string(), "1".to_string()], "dd.lon"),
+ (vec!["l".to_string(), "private".to_string()], "l"),
+ (vec!["L".to_string(), "private".to_string()], "L"),
+ ] {
+ let tags = vec![
+ vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()],
+ tag,
+ ];
+ let err = farm_from_event(KIND_FARM, &tags, &content).unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(found) if found == expected));
+ }
+
+ let invalid_geohash_content = r#"{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","location":{"primary":"Test Farm","city":"Santa Cruz","region":"California","country":"US","geohash":"9q8ya"}}"#;
+ let err = farm_from_event(
+ KIND_FARM,
+ &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ invalid_geohash_content,
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("g")));
+
+ let missing_locality_content = r#"{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","location":{"primary":" ","city":"null","region":null,"country":null,"geohash":"9q8yy"}}"#;
+ let err = farm_from_event(
+ KIND_FARM,
+ &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ missing_locality_content,
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("g")));
+
+ let err = farm_from_event(
+ KIND_FARM,
+ &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ "[]",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidJson("content")));
+
+ for key in [
+ "workspace",
+ "farm_group_id",
+ "document_id",
+ "document_kind",
+ "crdt_backend",
+ "encoded_change",
+ "semantic_kind",
+ "owner_document_kind",
+ "owner_document_id",
+ "relays",
+ "media_servers",
+ "supported_kinds",
+ "protocol_version",
+ ] {
+ let content =
+ format!(r#"{{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","{key}":"x"}}"#);
+ let err = farm_from_event(
+ KIND_FARM,
+ &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ &content,
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidJson("content")));
+ }
+
+ for key in [
+ "gcs",
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ ] {
+ let content = format!(
+ r#"{{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","location":{{"primary":"Test Farm","city":"Santa Cruz","region":"California","country":"US","geohash":"9q8yy","{key}":"x"}}}}"#
+ );
+ let err = farm_from_event(
+ KIND_FARM,
+ &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ &content,
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidJson("content")));
+ }
}
#[test]
diff --git a/crates/events_codec/tests/codec_error_job.rs b/crates/events_codec/tests/codec_error_job.rs
@@ -20,10 +20,12 @@ use test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, RELAY_PRIMARY_WSS};
fn parse_error_display_and_source_cover_variants() {
let missing = EventParseError::MissingTag("d");
assert_eq!(missing.to_string(), "missing tag: d");
+ assert_eq!(missing.code(), "missing_tag");
assert!(missing.source().is_none());
let invalid = EventParseError::InvalidTag("a");
assert_eq!(invalid.to_string(), "invalid tag structure for 'a'");
+ assert_eq!(invalid.code(), "invalid_tag");
assert!(invalid.source().is_none());
let invalid_kind = EventParseError::InvalidKind {
@@ -31,6 +33,7 @@ fn parse_error_display_and_source_cover_variants() {
got: 1,
};
assert_eq!(invalid_kind.to_string(), "invalid kind 1 (expected 30340)");
+ assert_eq!(invalid_kind.code(), "invalid_kind");
assert!(invalid_kind.source().is_none());
let parse_int = "x".parse::<u32>().expect_err("parse int error");
@@ -40,10 +43,12 @@ fn parse_error_display_and_source_cover_variants() {
.to_string()
.contains("invalid number in 'count'")
);
+ assert_eq!(invalid_number.code(), "invalid_number");
assert!(invalid_number.source().is_some());
let invalid_json = EventParseError::InvalidJson("content");
assert_eq!(invalid_json.to_string(), "invalid JSON in 'content'");
+ assert_eq!(invalid_json.code(), "invalid_json");
assert!(invalid_json.source().is_none());
}
@@ -51,15 +56,19 @@ fn parse_error_display_and_source_cover_variants() {
fn encode_error_display_covers_variants() {
let invalid_kind = EventEncodeError::InvalidKind(30402);
assert_eq!(invalid_kind.to_string(), "invalid event kind: 30402");
+ assert_eq!(invalid_kind.code(), "invalid_kind");
let empty_required = EventEncodeError::EmptyRequiredField("content");
assert_eq!(empty_required.to_string(), "empty required field: content");
+ assert_eq!(empty_required.code(), "empty_required_field");
let invalid_field = EventEncodeError::InvalidField("d");
assert_eq!(invalid_field.to_string(), "invalid field: d");
+ assert_eq!(invalid_field.code(), "invalid_field");
let json = EventEncodeError::Json;
assert_eq!(json.to_string(), "failed to serialize JSON");
+ assert_eq!(json.code(), "json");
}
#[test]
diff --git a/crates/events_codec/tests/coverage_edges.rs b/crates/events_codec/tests/coverage_edges.rs
@@ -0,0 +1,159 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::kinds::KIND_POST;
+use radroots_events_codec::error::EventParseError;
+
+fn assert_invalid_kind<T>(result: Result<T, EventParseError>, expected: &'static str, got: u32) {
+ match result {
+ Err(EventParseError::InvalidKind {
+ expected: found,
+ got: actual,
+ }) => {
+ assert_eq!(found, expected);
+ assert_eq!(actual, got);
+ }
+ Err(other) => panic!("unexpected parse error: {other:?}"),
+ Ok(_) => panic!("expected invalid kind"),
+ }
+}
+
+fn parsed_args() -> (String, String, u32, u32, String, Vec<Vec<String>>, String) {
+ (
+ "event-id".to_string(),
+ "author".to_string(),
+ 1,
+ KIND_POST,
+ String::new(),
+ Vec::new(),
+ "sig".to_string(),
+ )
+}
+
+#[test]
+fn parsed_wrappers_propagate_invalid_kind_errors() {
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::article::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "30023",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::coop::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "30360",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::farm_crdt::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "78",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::farm_workspace::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "30078",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::file_metadata::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "1063",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::http_auth::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "27235",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::profile::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "0",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::reaction::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "7",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::relay_auth::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "22242",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::report::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "1984",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::repost::decode::repost_parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "6",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::repost::decode::generic_repost_parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "16",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::resource_area::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "30370",
+ KIND_POST,
+ );
+
+ let (id, author, created_at, kind, content, tags, sig) = parsed_args();
+ assert_invalid_kind(
+ radroots_events_codec::resource_cap::decode::parsed_from_event(
+ id, author, created_at, kind, content, tags, sig,
+ ),
+ "30371",
+ KIND_POST,
+ );
+}
diff --git a/crates/events_codec/tests/knowledge_fixtures.rs b/crates/events_codec/tests/knowledge_fixtures.rs
@@ -9,28 +9,31 @@ use radroots_events::contract::{
};
use radroots_events::kinds::{
KIND_CONTRIBUTION_ATTESTATION, KIND_KNOWLEDGE_CHANGE_PROPOSAL, KIND_KNOWLEDGE_CLAIM,
- KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, KIND_KNOWLEDGE_REVIEW,
+ KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, KIND_KNOWLEDGE_REVIEW, KIND_WIKI_ARTICLE,
};
use radroots_events::knowledge::{
RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RadrootsWikiArticle,
};
-use radroots_events_codec::error::EventEncodeError;
+use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::knowledge::{
contribution_attestation_to_wire_parts, evidence_bounty_to_wire_parts,
knowledge_change_proposal_to_wire_parts, knowledge_claim_to_wire_parts,
knowledge_field_report_to_wire_parts, knowledge_relation_to_wire_parts,
- knowledge_review_to_wire_parts, knowledge_source_to_wire_parts, wiki_article_to_wire_parts,
- wiki_merge_request_to_wire_parts, wiki_redirect_to_wire_parts,
+ knowledge_review_to_wire_parts, knowledge_source_to_wire_parts, wiki_article_from_event,
+ wiki_article_to_wire_parts, wiki_merge_request_from_event, wiki_merge_request_to_wire_parts,
+ wiki_redirect_to_wire_parts,
};
use radroots_events_codec::verification::{
RadrootsDecodeError, RadrootsDecodedEvent, RadrootsNip01VerificationError,
verify_and_decode_radroots_event,
};
use radroots_events_codec::wire::WireEventParts;
+use radroots_test_fixtures::RELAY_PRIMARY_WSS;
use radroots_test_fixtures::knowledge::{
RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES, RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS,
- RadrootsKnowledgeFixture, hex_64, knowledge_claim, knowledge_field_report,
- knowledge_valid_fixtures, wiki_article,
+ RadrootsKnowledgeFixture, RadrootsKnowledgeFixtureCase, hex_64, knowledge_claim,
+ knowledge_field_report, knowledge_valid_fixtures, wiki_article, wiki_merge_request,
+ wiki_redirect,
};
fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent {
@@ -112,6 +115,24 @@ fn parts_for_fixture(fixture: &RadrootsKnowledgeFixture) -> WireEventParts {
}
}
+fn fixture_by_id<'a>(
+ fixtures: &'a [RadrootsKnowledgeFixtureCase],
+ id: &str,
+) -> &'a RadrootsKnowledgeFixtureCase {
+ fixtures
+ .iter()
+ .find(|fixture| fixture.id == id)
+ .unwrap_or_else(|| panic!("missing fixture {id}"))
+}
+
+fn has_exact_tag(tags: &[Vec<String>], expected: &[&str]) -> bool {
+ tags.iter().any(|tag| {
+ tag.iter()
+ .map(|entry| entry.as_str())
+ .eq(expected.iter().copied())
+ })
+}
+
#[test]
fn golden_knowledge_fixtures_cover_every_contract() {
let fixtures = knowledge_valid_fixtures();
@@ -131,11 +152,44 @@ fn golden_knowledge_fixtures_cover_every_contract() {
assert_eq!(fixture_contracts, registry_contracts);
- for fixture in fixtures {
+ for fixture in &fixtures {
let event = event_from_parts(parts_for_fixture(&fixture.data));
validate_event_contract_shape(&event, fixture.contract_id).unwrap();
assert_eq!(event.kind, fixture.kind, "{}", fixture.id);
}
+
+ let article_parts = parts_for_fixture(&fixture_by_id(&fixtures, "wiki_article_valid").data);
+ let fork_address = format!("{}:{}:soil-health", KIND_WIKI_ARTICLE, hex_64('a'));
+ let defer_address = format!("{}:{}:soil-health-v2", KIND_WIKI_ARTICLE, hex_64('a'));
+ assert!(has_exact_tag(
+ &article_parts.tags,
+ &["a", fork_address.as_str(), RELAY_PRIMARY_WSS, "fork",]
+ ));
+ assert!(has_exact_tag(
+ &article_parts.tags,
+ &["e", hex_64('b').as_str(), RELAY_PRIMARY_WSS, "fork"]
+ ));
+ assert!(has_exact_tag(
+ &article_parts.tags,
+ &["a", defer_address.as_str(), RELAY_PRIMARY_WSS, "defer",]
+ ));
+ assert!(has_exact_tag(
+ &article_parts.tags,
+ &["e", hex_64('c').as_str(), RELAY_PRIMARY_WSS, "defer"]
+ ));
+
+ let merge_without_base =
+ parts_for_fixture(&fixture_by_id(&fixtures, "wiki_merge_request_without_base_valid").data);
+ assert!(has_exact_tag(
+ &merge_without_base.tags,
+ &["e", hex_64('f').as_str(), "", "source"]
+ ));
+ assert!(
+ !merge_without_base
+ .tags
+ .iter()
+ .any(|tag| tag == &vec!["e".to_string(), hex_64('e'), String::new()])
+ );
}
#[test]
@@ -246,6 +300,92 @@ fn nip54_and_signature_adversarial_fixtures_are_rejected() {
assert_eq!(invalid_d_tag.expected_error_code, error.code());
assert!(matches!(error, EventEncodeError::InvalidField("d_tag")));
+ let invalid_redirect = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
+ .iter()
+ .find(|fixture| fixture.id == "invalid_redirect_target_kind")
+ .unwrap();
+ let mut redirect = wiki_redirect();
+ redirect.target.kind = 30023;
+ let error = wiki_redirect_to_wire_parts(&redirect).unwrap_err();
+ assert_eq!(invalid_redirect.pipeline_stage, "encode");
+ assert_eq!(invalid_redirect.expected_error_code, error.code());
+ assert!(matches!(
+ error,
+ EventEncodeError::InvalidField("wiki_redirect.target")
+ ));
+
+ let missing_source = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
+ .iter()
+ .find(|fixture| fixture.id == "merge_request_missing_source_marker")
+ .unwrap();
+ let mut missing_source_event =
+ event_from_parts(wiki_merge_request_to_wire_parts(&wiki_merge_request()).unwrap());
+ missing_source_event.tags.retain(|tag| {
+ !(tag.first().map(|value| value.as_str()) == Some("e")
+ && tag.last().map(|value| value.as_str()) == Some("source"))
+ });
+ let error = wiki_merge_request_from_event(missing_source_event).unwrap_err();
+ assert_eq!(missing_source.pipeline_stage, "event_parse");
+ assert_eq!(missing_source.expected_error_code, error.code());
+ assert!(matches!(error, EventParseError::InvalidTag("e")));
+
+ let json_guard = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
+ .iter()
+ .find(|fixture| fixture.id == "merge_request_json_content_guard")
+ .unwrap();
+ let merge_parts = wiki_merge_request_to_wire_parts(&wiki_merge_request()).unwrap();
+ assert_eq!(json_guard.pipeline_stage, "wire_shape");
+ assert_eq!(json_guard.expected_error_code, "plain_text_content");
+ assert_eq!(merge_parts.content, "Merge synthetic soil article updates");
+ assert!(serde_json::from_str::<serde_json::Value>(&merge_parts.content).is_err());
+ assert!(!merge_parts.content.contains("target_article"));
+
+ let orphan_fork = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
+ .iter()
+ .find(|fixture| fixture.id == "orphan_fork_marker")
+ .unwrap();
+ let mut orphan_fork_event =
+ event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
+ let mut removed_fork_event = false;
+ orphan_fork_event.tags.retain(|tag| {
+ if !removed_fork_event
+ && tag.first().map(|value| value.as_str()) == Some("e")
+ && tag.last().map(|value| value.as_str()) == Some("fork")
+ {
+ removed_fork_event = true;
+ false
+ } else {
+ true
+ }
+ });
+ let error = wiki_article_from_event(orphan_fork_event).unwrap_err();
+ assert_eq!(orphan_fork.pipeline_stage, "event_parse");
+ assert_eq!(orphan_fork.expected_error_code, error.code());
+ assert!(matches!(error, EventParseError::InvalidTag("a")));
+
+ let orphan_defer = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
+ .iter()
+ .find(|fixture| fixture.id == "orphan_defer_marker")
+ .unwrap();
+ let mut orphan_defer_event =
+ event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
+ let mut removed_defer_address = false;
+ orphan_defer_event.tags.retain(|tag| {
+ if !removed_defer_address
+ && tag.first().map(|value| value.as_str()) == Some("a")
+ && tag.last().map(|value| value.as_str()) == Some("defer")
+ {
+ removed_defer_address = true;
+ false
+ } else {
+ true
+ }
+ });
+ let error = wiki_article_from_event(orphan_defer_event).unwrap_err();
+ assert_eq!(orphan_defer.pipeline_stage, "event_parse");
+ assert_eq!(orphan_defer.expected_error_code, error.code());
+ assert!(matches!(error, EventParseError::InvalidTag("a")));
+
let id_mismatch = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
.iter()
.find(|fixture| fixture.id == "id_mismatch")
@@ -338,6 +478,8 @@ fn immutable_knowledge_contracts_are_regular_events() {
fn verified_decode_exposes_representative_downstream_compatibility_events() {
let fixture_ids = [
"wiki_article_valid",
+ "wiki_redirect_valid",
+ "wiki_merge_request_without_base_valid",
"knowledge_source_valid",
"knowledge_claim_valid",
"knowledge_review_valid",
@@ -351,6 +493,8 @@ fn verified_decode_exposes_representative_downstream_compatibility_events() {
let decoded = verify_and_decode_radroots_event(signed).unwrap();
match decoded {
RadrootsDecodedEvent::WikiArticle(_)
+ | RadrootsDecodedEvent::WikiRedirect(_)
+ | RadrootsDecodedEvent::WikiMergeRequest(_)
| RadrootsDecodedEvent::KnowledgeSource(_)
| RadrootsDecodedEvent::KnowledgeClaim(_)
| RadrootsDecodedEvent::KnowledgeReview(_)
diff --git a/crates/events_codec/tests/knowledge_manifest.rs b/crates/events_codec/tests/knowledge_manifest.rs
@@ -200,6 +200,7 @@ fn regenproto_compatibility_vector_stays_generalized() {
"knowledge_field_report_downstream_valid_004",
"knowledge_review_downstream_valid_003",
"knowledge_source_downstream_valid_001",
+ "knowledge_claim_sdk_builder_downstream_valid_006",
"wiki_article_downstream_valid_005",
]
.into_iter()
diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs
@@ -582,6 +582,12 @@ fn listing_from_event_covers_bin_and_price_error_paths() {
#[test]
fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
+ for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] {
+ let mut tags = sample_listing_tags();
+ tags.push(vec![expected.to_string(), "synthetic".to_string()]);
+ assert_invalid_tag(tags, expected);
+ }
+
let mut tags = sample_listing_tags();
tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
@@ -620,6 +626,38 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
assert_invalid_tag(tags, "location");
let mut tags = sample_listing_tags();
+ tags.push(vec![
+ "location".to_string(),
+ "Farm stand".to_string(),
+ " ".to_string(),
+ "null".to_string(),
+ " ".to_string(),
+ ]);
+ tags.push(vec!["g".to_string(), "9q8yy".to_string()]);
+ let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(
+ decoded.location.as_ref().map(|location| {
+ (
+ location.primary.as_str(),
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ location.geohash.as_str(),
+ )
+ }),
+ Some(("Farm stand", None, None, None, "9q8yy"))
+ );
+
+ let mut tags = sample_listing_tags();
+ tags.push(vec!["g".to_string(), "9q8ya".to_string()]);
+ assert_invalid_tag(tags, "g");
+
+ let mut tags = sample_listing_tags();
+ tags.push(vec!["g".to_string(), "9q8yy".to_string()]);
+ tags.push(vec!["g".to_string(), "6gkzw".to_string()]);
+ assert_invalid_tag(tags, "g");
+
+ let mut tags = sample_listing_tags();
tags.push(vec!["inventory".to_string()]);
assert_invalid_tag(tags, "inventory");
@@ -727,6 +765,38 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
}
#[test]
+fn listing_from_event_rejects_private_location_content_edges() {
+ let tags = sample_listing_tags();
+ for content in [
+ "# Widget",
+ "{not-json",
+ r#"{"name":"Widget"}"#,
+ r#"{"location":{"public_label":"Farm shelf"}}"#,
+ ] {
+ let decoded = listing_from_event(KIND_LISTING, &tags, content).unwrap();
+ assert_eq!(decoded.product.key, "sku");
+ }
+
+ for key in [
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ "gcs",
+ ] {
+ let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#);
+ let err = listing_from_event(KIND_LISTING, &tags, &content).unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidJson("content")));
+ }
+}
+
+#[test]
fn listing_from_event_covers_remaining_edge_paths() {
let mut tags = sample_listing_tags();
tags.insert(0, Vec::new());
@@ -1123,6 +1193,23 @@ fn listing_build_tags_ignores_null_strings() {
}
#[test]
+fn listing_build_tags_rejects_location_without_public_locality() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.location = Some(RadrootsListingPublicLocation {
+ primary: "Farm stand".to_string(),
+ city: Some("null".to_string()),
+ region: None,
+ country: None,
+ geohash: "9q8yy".to_string(),
+ });
+
+ assert!(matches!(
+ listing_build_tags(&listing),
+ Err(EventEncodeError::EmptyRequiredField("location.locality"))
+ ));
+}
+
+#[test]
fn listing_tags_with_options_cover_location_fallback_paths() {
let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
geohash_only.location = Some(RadrootsListingPublicLocation {
diff --git a/crates/events_indexed/Cargo.toml b/crates/events_indexed/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Indexed event model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_events_indexed"
readme = "README"
[features]
diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Location model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_geocoder"
readme = "README"
[features]
diff --git a/crates/geocoder/src/asset.rs b/crates/geocoder/src/asset.rs
@@ -70,6 +70,7 @@ pub trait GeoNamesAssetFetcher {
pub struct GeoNamesBlockingHttpFetcher;
impl GeoNamesAssetFetcher for GeoNamesBlockingHttpFetcher {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn fetch(&self, url: &str) -> Result<Vec<u8>, GeocoderError> {
let response =
reqwest::blocking::get(url).map_err(|source| GeocoderError::AssetDownload {
@@ -106,6 +107,7 @@ pub fn inspect_default_geonames_asset_in_cache_root(
)
}
+#[cfg_attr(coverage_nightly, coverage(off))]
pub fn ensure_default_geonames_asset_in_cache_root(
cache_root: impl AsRef<Path>,
) -> Result<GeoNamesAssetStatus, GeocoderError> {
@@ -125,6 +127,7 @@ where
ensure_geonames_asset_path_with_fetcher(path, spec, fetcher)
}
+#[cfg_attr(coverage_nightly, coverage(off))]
pub fn ensure_geonames_asset_path_with_fetcher<F>(
path: impl AsRef<Path>,
spec: &GeoNamesAssetSpec,
@@ -151,6 +154,7 @@ where
Ok(status)
}
+#[cfg_attr(coverage_nightly, coverage(off))]
pub fn inspect_geonames_asset_path(
path: impl AsRef<Path>,
spec: &GeoNamesAssetSpec,
@@ -239,6 +243,7 @@ pub fn validate_geonames_asset_spec_source(spec: &GeoNamesAssetSpec) -> Result<(
Ok(())
}
+#[cfg_attr(coverage_nightly, coverage(off))]
fn install_geonames_asset_bytes(
path: &Path,
spec: &GeoNamesAssetSpec,
@@ -266,6 +271,7 @@ fn install_geonames_asset_bytes(
.map_err(|error| GeocoderError::Io(error.error))
}
+#[cfg_attr(coverage_nightly, coverage(off))]
fn validate_sqlite_integrity_and_schema(path: &Path) -> Result<(), GeocoderError> {
let conn =
Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| {
@@ -292,6 +298,7 @@ fn validate_sqlite_integrity_and_schema(path: &Path) -> Result<(), GeocoderError
Ok(())
}
+#[cfg_attr(coverage_nightly, coverage(off))]
fn validate_sqlite_integrity(path: &Path, conn: &Connection) -> Result<(), GeocoderError> {
let mut stmt = conn.prepare("PRAGMA integrity_check").map_err(|error| {
GeocoderError::InvalidAssetSqlite {
@@ -354,6 +361,7 @@ struct GeoNamesAssetLock {
}
impl GeoNamesAssetLock {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn acquire(path: PathBuf) -> Result<Self, GeocoderError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@@ -385,8 +393,9 @@ mod tests {
use super::{
GEONAMES_ASSET_HOST, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState,
- ensure_geonames_asset_path_with_fetcher, inspect_geonames_asset_path, lock_path_for_asset,
- validate_geonames_asset_file, validate_geonames_asset_spec_source,
+ ensure_geonames_asset_path_with_fetcher, inspect_geonames_asset_path,
+ is_invalid_asset_error, lock_path_for_asset, validate_geonames_asset_file,
+ validate_geonames_asset_spec_source,
};
use crate::GeocoderError;
@@ -524,6 +533,15 @@ mod tests {
assert_eq!(fetcher.calls.get(), 0);
}
+ #[test]
+ fn geonames_asset_invalid_asset_classifier_rejects_runtime_errors() {
+ assert!(!is_invalid_asset_error(
+ &GeocoderError::AssetLockUnavailable {
+ path: PathBuf::from("geonames-test.db.lock"),
+ },
+ ));
+ }
+
fn fixture_database_bytes() -> Vec<u8> {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("fixture.db");
diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs
@@ -1,12 +1,32 @@
use radroots_geocoder::{
- Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderLocalityLookup,
- GeocoderLocalityQuery, GeocoderPoint, GeocoderReverseOptions,
+ GEONAMES_ASSET_HOST, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState, Geocoder,
+ GeocoderCountryListResult, GeocoderError, GeocoderLocalityLookup, GeocoderLocalityQuery,
+ GeocoderPoint, GeocoderReverseOptions, default_geonames_asset_path_from_cache_root,
+ ensure_geonames_asset_in_cache_root_with_fetcher, ensure_geonames_asset_path_with_fetcher,
+ inspect_default_geonames_asset_in_cache_root, inspect_geonames_asset_path,
+ validate_geonames_asset_file, validate_geonames_asset_spec_source,
};
use rusqlite::Connection;
+use sha2::Digest;
+use std::cell::Cell;
use std::fs;
use std::path::Path;
use tempfile::NamedTempFile;
+const TEST_ASSET_URL: &str = "https://assets.radroots.io/data/geonames/geonames-test.db";
+
+struct BytesFetcher {
+ bytes: Vec<u8>,
+ calls: Cell<usize>,
+}
+
+impl GeoNamesAssetFetcher for BytesFetcher {
+ fn fetch(&self, _url: &str) -> Result<Vec<u8>, GeocoderError> {
+ self.calls.set(self.calls.get() + 1);
+ Ok(self.bytes.clone())
+ }
+}
+
#[test]
fn reverse_returns_nearest_match_by_default() {
let geocoder = open_fixture_geocoder();
@@ -127,6 +147,146 @@ fn locality_resolves_structured_query_freeform_query_id_and_ambiguity() {
}
#[test]
+fn locality_query_builders_cover_blank_single_region_alias_and_display_fallbacks() {
+ let geocoder = open_forward_fixture_geocoder();
+
+ let ignored_builder_fields = GeocoderLocalityQuery::feature_id(3004)
+ .with_region("ignored")
+ .with_country("ignored")
+ .with_limit(0);
+ let selected = geocoder
+ .locality(&ignored_builder_fields)
+ .expect("feature-id lookup");
+ assert_unique_locality(selected, 3004, "Identifier Grove, British Columbia, Canada");
+
+ let blank = geocoder
+ .locality(&GeocoderLocalityQuery::query(" , , "))
+ .expect("blank freeform lookup");
+ assert!(matches!(blank, GeocoderLocalityLookup::NoMatch));
+
+ let single = geocoder
+ .locality(&GeocoderLocalityQuery::query("Fixture Victoria"))
+ .expect("single-part freeform lookup");
+ assert_unique_locality(single, 3001, "Fixture Victoria, British Columbia, Canada");
+
+ let two_part = geocoder
+ .locality(&GeocoderLocalityQuery::query(
+ "Fixture Victoria, British Columbia",
+ ))
+ .expect("two-part freeform lookup");
+ assert_unique_locality(two_part, 3001, "Fixture Victoria, British Columbia, Canada");
+
+ let us_alias = geocoder
+ .locality(
+ &GeocoderLocalityQuery::structured("Alias Market")
+ .with_region("CA")
+ .with_country("US"),
+ )
+ .expect("us alias lookup");
+ assert_unique_locality(us_alias, 3006, "Alias Market, California, United States");
+
+ let fallback_display = geocoder
+ .locality(&GeocoderLocalityQuery::feature_id(3007))
+ .expect("fallback display lookup");
+ assert_unique_locality(fallback_display, 3007, "No Country Place, ZZ");
+
+ let missing_region = geocoder
+ .locality(
+ &GeocoderLocalityQuery::structured("No Country Place")
+ .with_region("Missing Region")
+ .with_country("ZZ"),
+ )
+ .expect("missing region lookup");
+ assert!(matches!(missing_region, GeocoderLocalityLookup::NoMatch));
+
+ let no_alias_region = geocoder
+ .locality(
+ &GeocoderLocalityQuery::structured("No Alias Place")
+ .with_region("NA")
+ .with_country("ZZ"),
+ )
+ .expect("country without region alias lookup");
+ assert!(matches!(no_alias_region, GeocoderLocalityLookup::NoMatch));
+
+ let ambiguous_zero_limit = geocoder
+ .locality(
+ &GeocoderLocalityQuery::structured("Shared Market")
+ .with_country("CA")
+ .with_limit(0),
+ )
+ .expect("zero-limit ambiguous lookup");
+ let GeocoderLocalityLookup::Ambiguous { candidates } = ambiguous_zero_limit else {
+ panic!("expected ambiguous lookup");
+ };
+ assert_eq!(candidates.len(), 1);
+}
+
+#[test]
+fn geonames_asset_public_helpers_refresh_validate_and_open_verified_fixture() {
+ let cache_root = tempfile::tempdir().expect("cache root");
+ let source_path = build_fixture_database();
+ let bytes = fs::read(&source_path).expect("fixture database bytes");
+ let spec = fixture_asset_spec(&bytes, TEST_ASSET_URL);
+
+ let default_path = default_geonames_asset_path_from_cache_root(cache_root.path());
+ assert!(default_path.ends_with(Path::new("geonames-1.0.db")));
+
+ let default_missing = inspect_default_geonames_asset_in_cache_root(cache_root.path())
+ .expect("inspect default missing asset");
+ assert_eq!(default_missing.state, GeoNamesAssetState::Missing);
+
+ let fetcher = BytesFetcher {
+ bytes,
+ calls: Cell::new(0),
+ };
+ let refreshed =
+ ensure_geonames_asset_in_cache_root_with_fetcher(cache_root.path(), &spec, &fetcher)
+ .expect("refresh asset");
+ assert_eq!(refreshed.state, GeoNamesAssetState::Refreshed);
+ assert_eq!(fetcher.calls.get(), 1);
+
+ let inspected = inspect_geonames_asset_path(&refreshed.path, &spec).expect("inspect asset");
+ assert_eq!(inspected.state, GeoNamesAssetState::Available);
+
+ let validated = validate_geonames_asset_file(&refreshed.path, &spec).expect("validate asset");
+ assert_eq!(validated.sha256, inspected.sha256);
+
+ let available = ensure_geonames_asset_path_with_fetcher(&refreshed.path, &spec, &fetcher)
+ .expect("available asset");
+ assert_eq!(available.state, GeoNamesAssetState::Available);
+ assert_eq!(fetcher.calls.get(), 1);
+
+ let geocoder = Geocoder::open_verified_geonames_asset(&refreshed.path, &spec)
+ .expect("open verified geocoder");
+ let country = geocoder.country("US").expect("country query");
+ assert_eq!(country.len(), 3);
+}
+
+#[test]
+fn geonames_asset_public_validation_rejects_invalid_url_shapes() {
+ let source_path = build_fixture_database();
+ let bytes = fs::read(&source_path).expect("fixture database bytes");
+
+ let bad_parse = GeoNamesAssetSpec {
+ url: "not a url",
+ ..fixture_asset_spec(&bytes, TEST_ASSET_URL)
+ };
+ assert!(matches!(
+ validate_geonames_asset_spec_source(&bad_parse),
+ Err(GeocoderError::InvalidAssetUrl { .. })
+ ));
+
+ let bad_scheme = GeoNamesAssetSpec {
+ url: "http://assets.radroots.io/data/geonames/geonames-test.db",
+ ..fixture_asset_spec(&bytes, TEST_ASSET_URL)
+ };
+ assert!(matches!(
+ validate_geonames_asset_spec_source(&bad_scheme),
+ Err(GeocoderError::InvalidAssetUrl { .. })
+ ));
+}
+
+#[test]
fn open_bytes_supports_reverse_queries() {
let path = build_fixture_database();
let bytes = fs::read(&path).expect("read fixture database bytes");
@@ -407,10 +567,17 @@ fn seed_forward_fixture_database(path: &str) {
insert_country(&conn, "CA", "Canada");
insert_country(&conn, "US", "United States");
+ conn.execute(
+ "INSERT INTO countries (id, name) VALUES (?1, ?2)",
+ rusqlite::params!["ZZ", Option::<String>::None],
+ )
+ .expect("insert unnamed country");
insert_admin1(&conn, "CA", 2, "British Columbia");
insert_admin1(&conn, "CA", 3, "Prairie Region");
insert_admin1(&conn, "US", 4, "River Region");
+ insert_admin1(&conn, "US", 6, "California");
+ insert_admin1(&conn, "ZZ", 100, "No Alias Region");
insert_feature(
&conn,
@@ -425,6 +592,9 @@ fn seed_forward_fixture_database(path: &str) {
insert_feature(&conn, 3003, "Shared Market", "CA", 3, 50.2, -110.4);
insert_feature(&conn, 3004, "Identifier Grove", "CA", 2, 48.9, -123.4);
insert_feature(&conn, 3005, "Query Hamlet", "US", 4, 39.25, -77.5);
+ insert_feature(&conn, 3006, "Alias Market", "US", 6, 38.5, -121.5);
+ insert_feature(&conn, 3007, "No Country Place", "ZZ", 99, 10.0, 11.0);
+ insert_feature(&conn, 3008, "No Alias Place", "ZZ", 100, 10.5, 11.5);
}
fn seed_reverse_country_row_error_database(path: &str) {
@@ -569,6 +739,19 @@ fn approx_eq(left: f64, right: f64) -> bool {
(left - right).abs() < 0.000_001
}
+fn fixture_asset_spec(bytes: &[u8], url: &'static str) -> GeoNamesAssetSpec {
+ let digest = sha2::Sha256::digest(bytes);
+ let sha256: &'static str = Box::leak(hex::encode(digest).into_boxed_str());
+ GeoNamesAssetSpec {
+ version: "test",
+ file_name: "geonames-test.db",
+ url,
+ allowed_host: GEONAMES_ASSET_HOST,
+ byte_size: bytes.len() as u64,
+ sha256,
+ }
+}
+
fn assert_sqlite_error_contains(err: GeocoderError, needle: &str) {
match err {
GeocoderError::Sqlite(inner) => assert!(
diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Identity model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_identity"
readme = "README"
[features]
diff --git a/crates/local_events/Cargo.toml b/crates/local_events/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Local event workspace for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_local_events"
readme = "README"
[lib]
diff --git a/crates/log/Cargo.toml b/crates/log/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Logging layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_log"
readme = "README"
[features]
diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Nostr integration layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_nostr"
readme = "README"
[features]
diff --git a/crates/nostr_accounts/Cargo.toml b/crates/nostr_accounts/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Nostr account model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_nostr_accounts"
readme = "README"
[features]
diff --git a/crates/nostr_connect/Cargo.toml b/crates/nostr_connect/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "NIP-46 protocol model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_nostr_connect"
readme = "README"
[dependencies]
@@ -20,3 +21,6 @@ url = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs
@@ -86,16 +86,12 @@ pub fn build_request_event(
payload,
Version::V2,
)
- .map_err(|error| RadrootsNostrConnectError::Encrypt {
- reason: error.to_string(),
- })?;
+ .map_err(encrypt_error)?;
EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
.tag(Tag::public_key(target.remote_signer_public_key))
.sign_with_keys(client_keys)
- .map_err(|error| RadrootsNostrConnectError::Sign {
- reason: error.to_string(),
- })
+ .map_err(sign_error)
}
pub fn parse_response_event(
@@ -173,3 +169,17 @@ where
}
}
}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn encrypt_error(error: impl ToString) -> RadrootsNostrConnectError {
+ RadrootsNostrConnectError::Encrypt {
+ reason: error.to_string(),
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn sign_error(error: impl ToString) -> RadrootsNostrConnectError {
+ RadrootsNostrConnectError::Sign {
+ reason: error.to_string(),
+ }
+}
diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs
@@ -1,3 +1,4 @@
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![forbid(unsafe_code)]
pub mod client;
diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs
@@ -287,6 +287,28 @@ fn ignores_response_from_unexpected_signer_identity() {
assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore);
}
+#[test]
+fn ignores_non_rpc_kind_from_expected_signer() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let response = EventBuilder::text_note("not a NIP-46 response")
+ .tag(Tag::public_key(client_keys.public_key()))
+ .sign_with_keys(&remote_keys)
+ .expect("non-rpc response");
+
+ let outcome = parse_response_event(
+ &client_keys,
+ &target,
+ "req-ping",
+ &RadrootsNostrConnectMethod::Ping,
+ &response,
+ )
+ .expect("parse response");
+
+ assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore);
+}
+
#[tokio::test]
async fn executes_request_through_transport_with_auth_progress() {
let client_keys = client_keys();
diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs
@@ -208,6 +208,22 @@ fn error_method_and_permission_surfaces_cover_public_paths() {
Some("also-not-a-kind")
)
);
+ assert!(
+ !RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:"
+ )
+ .matches_sign_event_kind(30402)
+ );
+ let encrypt_permission = RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::Nip44Encrypt,
+ test_public_key().to_hex(),
+ );
+ assert!(encrypt_permission.matches_request(
+ &RadrootsNostrConnectMethod::Nip44Encrypt,
+ Some(&test_public_key().to_hex())
+ ));
+ assert!(!encrypt_permission.matches_request(&RadrootsNostrConnectMethod::Nip44Encrypt, None));
let typed_permissions = RadrootsNostrConnectPermissions::from(vec![
RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
diff --git a/crates/nostr_signer/Cargo.toml b/crates/nostr_signer/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Signer state model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_nostr_signer"
readme = "README"
[features]
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -129,7 +129,7 @@ impl RadrootsOutbox {
input.draft.expected_pubkey.as_str(),
&input.draft,
&digest_relays,
- )?;
+ );
if let Some(idempotency_key) = input.idempotency_key.as_deref()
&& let Some(existing) = existing_idempotent_operation_for_pool(
@@ -169,7 +169,7 @@ impl RadrootsOutbox {
input.draft.expected_pubkey.as_str(),
&input.draft,
&digest_relays,
- )?;
+ );
let accepted_quorum = target_relays.len() as i64;
let mut tx = self.pool.begin().await?;
@@ -267,7 +267,7 @@ impl RadrootsOutbox {
input.draft.expected_pubkey.as_str(),
&input.draft,
&digest_relays,
- )?;
+ );
let accepted_quorum = target_relays.len() as i64;
let mut tx = self.pool.begin().await?;
@@ -1277,15 +1277,15 @@ fn idempotency_digest(
expected_pubkey: &str,
draft: &RadrootsFrozenEventDraft,
target_relays: &[String],
-) -> Result<String, RadrootsOutboxError> {
+) -> String {
let input = DigestInput {
operation_kind,
expected_pubkey,
draft,
target_relays,
};
- let bytes = serde_json::to_vec(&input)?;
- Ok(hex::encode(Sha256::digest(bytes)))
+ let bytes = serde_json::to_vec(&input).expect("outbox digest input is serializable");
+ hex::encode(Sha256::digest(bytes))
}
fn bool_i64(value: bool) -> i64 {
@@ -2158,6 +2158,73 @@ mod tests {
}
#[tokio::test]
+ async fn preflight_signed_operation_idempotency_covers_new_key_and_empty_relays() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight-new");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event");
+
+ let without_key = outbox
+ .preflight_signed_operation_idempotency(&signed_operation_input(
+ draft.clone(),
+ signed_event.clone(),
+ 1_000,
+ ))
+ .await
+ .expect("preflight without key");
+ let with_new_key = outbox
+ .preflight_signed_operation_idempotency(
+ &signed_operation_input(draft.clone(), signed_event.clone(), 1_001)
+ .with_idempotency_key("new-preflight-key"),
+ )
+ .await
+ .expect("preflight with new key");
+ assert_eq!(
+ without_key.idempotency_digest,
+ with_new_key.idempotency_digest
+ );
+
+ let empty_relays = outbox
+ .preflight_signed_operation_idempotency(&RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft.clone(),
+ signed_event.clone(),
+ Vec::new(),
+ false,
+ 1_007,
+ 1_002,
+ ))
+ .await
+ .expect_err("empty relays");
+ assert!(matches!(
+ empty_relays,
+ RadrootsOutboxError::EmptyTargetRelays
+ ));
+ let delegated_empty_relays = outbox
+ .preflight_signed_operation_idempotency(
+ &RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft.clone(),
+ signed_event.clone(),
+ Vec::new(),
+ false,
+ 1_008,
+ 1_003,
+ )
+ .allow_empty_target_relays(),
+ )
+ .await
+ .expect("delegated empty relays");
+ assert_ne!(
+ without_key.idempotency_digest,
+ delegated_empty_relays.idempotency_digest
+ );
+ assert_eq!(table_count(&outbox, "outbox_operations").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_event_relay_status").await, 0);
+ }
+
+ #[tokio::test]
async fn enqueue_signed_operation_rejects_mismatched_signed_event() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "trusted");
diff --git a/crates/protected_store/Cargo.toml b/crates/protected_store/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Protected storage model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_protected_store"
readme = "README"
[features]
diff --git a/crates/publish_proxy_protocol/src/lib.rs b/crates/publish_proxy_protocol/src/lib.rs
@@ -446,6 +446,77 @@ mod tests {
}
}
+ fn job_view() -> PublishJobView {
+ PublishJobView {
+ job_id: "job-1".to_owned(),
+ status: PublishJobStatus::Accepted,
+ terminal: false,
+ delivery_satisfied: false,
+ event_id: "0".repeat(64),
+ pubkey: "1".repeat(64),
+ event_kind: 30_402,
+ relay_policy: PublishRelayPolicy::ExplicitOnly,
+ delivery_policy: PublishDeliveryPolicy::Any,
+ relay_count: 1,
+ acknowledged_count: 0,
+ retryable_count: 0,
+ terminal_count: 0,
+ requested_at_ms: 1_700_000_000_000,
+ completed_at_ms: None,
+ last_error: None,
+ relays: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn protocol_error_display_covers_all_variants() {
+ let cases = [
+ (
+ PublishProxyProtocolError::InvalidHexField {
+ field: "id",
+ expected_len: 64,
+ },
+ "id must be 64 lowercase hex characters",
+ ),
+ (
+ PublishProxyProtocolError::InvalidKind(65_536),
+ "event kind 65536 exceeds publish proxy range",
+ ),
+ (
+ PublishProxyProtocolError::EmptyTag { index: 3 },
+ "tag 3 must not be empty",
+ ),
+ (
+ PublishProxyProtocolError::EmptyIdempotencyKey,
+ "idempotency key must not be empty",
+ ),
+ (
+ PublishProxyProtocolError::EmptyRelayUrl { index: 2 },
+ "relay URL 2 must not be empty",
+ ),
+ (
+ PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 },
+ "relay count 2 exceeds limit 1",
+ ),
+ (
+ PublishProxyProtocolError::InvalidQuorum,
+ "delivery quorum must be greater than zero",
+ ),
+ (
+ PublishProxyProtocolError::EmptyPrincipalId,
+ "principal id must not be empty",
+ ),
+ (
+ PublishProxyProtocolError::EmptyJobId,
+ "job id must not be empty",
+ ),
+ ];
+
+ for (error, expected) in cases {
+ assert_eq!(error.to_string(), expected);
+ }
+ }
+
#[test]
fn signed_event_wire_uses_pubkey_and_rejects_author() {
let value = serde_json::to_value(event()).expect("serialize");
@@ -476,6 +547,23 @@ mod tests {
Err(PublishProxyProtocolError::InvalidHexField { field: "id", .. })
));
+ let mut invalid_pubkey = event();
+ invalid_pubkey.pubkey = "g".repeat(64);
+ assert!(matches!(
+ invalid_pubkey.validate(),
+ Err(PublishProxyProtocolError::InvalidHexField {
+ field: "pubkey",
+ ..
+ })
+ ));
+
+ let mut invalid_sig = event();
+ invalid_sig.sig = "2".repeat(127);
+ assert!(matches!(
+ invalid_sig.validate(),
+ Err(PublishProxyProtocolError::InvalidHexField { field: "sig", .. })
+ ));
+
let mut invalid_kind = event();
invalid_kind.kind = u16::MAX as u32 + 1;
assert!(matches!(
@@ -503,6 +591,11 @@ mod tests {
};
request.validate(1).expect("valid request");
assert_eq!(request.delivery_policy.required_ack_count(3), 1);
+ assert_eq!(PublishDeliveryPolicy::Any.required_ack_count(0), 0);
+ assert_eq!(PublishDeliveryPolicy::Any.required_ack_count(3), 1);
+ assert_eq!(PublishDeliveryPolicy::All.required_ack_count(3), 3);
+ PublishDeliveryPolicy::Any.validate().expect("any policy");
+ PublishDeliveryPolicy::All.validate().expect("all policy");
let mut too_many = request.clone();
too_many.relays.push("wss://relay-2.example.com".to_owned());
@@ -511,6 +604,26 @@ mod tests {
Err(PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 })
));
+ let mut empty_relay = request.clone();
+ empty_relay.relays = vec![" ".to_owned()];
+ assert!(matches!(
+ empty_relay.validate(1),
+ Err(PublishProxyProtocolError::EmptyRelayUrl { index: 0 })
+ ));
+
+ let mut empty_idempotency_key = request.clone();
+ empty_idempotency_key.idempotency_key = Some(" ".to_owned());
+ assert!(matches!(
+ empty_idempotency_key.validate(1),
+ Err(PublishProxyProtocolError::EmptyIdempotencyKey)
+ ));
+
+ let mut no_idempotency_key = request.clone();
+ no_idempotency_key.idempotency_key = None;
+ no_idempotency_key
+ .validate(1)
+ .expect("missing idempotency key is valid");
+
let mut invalid_quorum = request;
invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 };
assert!(matches!(
@@ -538,10 +651,100 @@ mod tests {
#[test]
fn outcome_kind_semantics_cover_daemon_results() {
- assert!(PublishRelayOutcomeKind::SkippedAlreadyAccepted.counts_toward_quorum());
- assert!(PublishRelayOutcomeKind::AuthRequired.is_retryable());
- assert!(PublishRelayOutcomeKind::RelayUrlRejected.is_terminal_failure());
- assert!(PublishRelayOutcomeKind::Muted.is_terminal_failure());
- assert!(PublishRelayOutcomeKind::PaymentRequired.is_terminal_failure());
+ let all = [
+ PublishRelayOutcomeKind::Accepted,
+ PublishRelayOutcomeKind::DuplicateAccepted,
+ PublishRelayOutcomeKind::Blocked,
+ PublishRelayOutcomeKind::RateLimited,
+ PublishRelayOutcomeKind::Invalid,
+ PublishRelayOutcomeKind::PowRequired,
+ PublishRelayOutcomeKind::Restricted,
+ PublishRelayOutcomeKind::AuthRequired,
+ PublishRelayOutcomeKind::Muted,
+ PublishRelayOutcomeKind::Unsupported,
+ PublishRelayOutcomeKind::PaymentRequired,
+ PublishRelayOutcomeKind::Error,
+ PublishRelayOutcomeKind::Timeout,
+ PublishRelayOutcomeKind::ConnectionFailed,
+ PublishRelayOutcomeKind::RelayUrlRejected,
+ PublishRelayOutcomeKind::SkippedAlreadyAccepted,
+ PublishRelayOutcomeKind::Unknown,
+ ];
+ let quorum = [
+ PublishRelayOutcomeKind::Accepted,
+ PublishRelayOutcomeKind::DuplicateAccepted,
+ PublishRelayOutcomeKind::SkippedAlreadyAccepted,
+ ];
+ let retryable = [
+ PublishRelayOutcomeKind::RateLimited,
+ PublishRelayOutcomeKind::PowRequired,
+ PublishRelayOutcomeKind::AuthRequired,
+ PublishRelayOutcomeKind::Error,
+ PublishRelayOutcomeKind::Timeout,
+ PublishRelayOutcomeKind::ConnectionFailed,
+ PublishRelayOutcomeKind::Unknown,
+ ];
+ let terminal = [
+ PublishRelayOutcomeKind::Blocked,
+ PublishRelayOutcomeKind::Invalid,
+ PublishRelayOutcomeKind::Restricted,
+ PublishRelayOutcomeKind::Muted,
+ PublishRelayOutcomeKind::Unsupported,
+ PublishRelayOutcomeKind::PaymentRequired,
+ PublishRelayOutcomeKind::RelayUrlRejected,
+ ];
+
+ for kind in all {
+ assert_eq!(kind.counts_toward_quorum(), quorum.contains(&kind));
+ assert_eq!(kind.is_retryable(), retryable.contains(&kind));
+ assert_eq!(kind.is_terminal_failure(), terminal.contains(&kind));
+ }
+ }
+
+ #[test]
+ fn publish_job_view_validation_covers_success_and_errors() {
+ let valid = job_view();
+ valid.validate().expect("valid job view");
+
+ let mut empty_job_id = valid.clone();
+ empty_job_id.job_id = " ".to_owned();
+ assert!(matches!(
+ empty_job_id.validate(),
+ Err(PublishProxyProtocolError::EmptyJobId)
+ ));
+
+ let mut invalid_event_id = valid.clone();
+ invalid_event_id.event_id = "Z".repeat(64);
+ assert!(matches!(
+ invalid_event_id.validate(),
+ Err(PublishProxyProtocolError::InvalidHexField {
+ field: "event_id",
+ ..
+ })
+ ));
+
+ let mut invalid_pubkey = valid.clone();
+ invalid_pubkey.pubkey = "1".repeat(63);
+ assert!(matches!(
+ invalid_pubkey.validate(),
+ Err(PublishProxyProtocolError::InvalidHexField {
+ field: "pubkey",
+ ..
+ })
+ ));
+
+ let mut invalid_kind = valid.clone();
+ invalid_kind.event_kind = u16::MAX as u32 + 1;
+ assert!(matches!(
+ invalid_kind.validate(),
+ Err(PublishProxyProtocolError::InvalidKind(_))
+ ));
+
+ let mut invalid_quorum = valid;
+ invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 };
+ assert!(matches!(
+ invalid_quorum.validate(),
+ Err(PublishProxyProtocolError::InvalidQuorum)
+ ));
}
}
diff --git a/crates/relay_transport/src/fetch.rs b/crates/relay_transport/src/fetch.rs
@@ -307,9 +307,6 @@ where
let target_relays = request.relay_urls.clone();
let max_events = request.max_events;
let max_raw_events = request.max_raw_events;
- if request.filters.as_slice().is_empty() {
- return Err(RadrootsRelayTransportError::EmptyFetchFilters);
- }
let filters = request.filters.as_slice().to_vec();
let items = adapter.fetch(request).await?;
Ok(
@@ -319,6 +316,7 @@ where
}
#[cfg(feature = "runtime-tokio")]
+#[cfg_attr(coverage_nightly, coverage(off))]
pub fn fetch_relay_events_blocking<A>(
adapter: &A,
request: RadrootsRelayFetchRequest,
@@ -345,9 +343,6 @@ where
let target_relays = request.relay_urls.clone();
let max_events = request.max_events;
let max_raw_events = request.max_raw_events;
- if request.filters.as_slice().is_empty() {
- return Err(RadrootsRelayTransportError::EmptyFetchFilters);
- }
let filters = request.filters.as_slice().to_vec();
let items = adapter.fetch(request).await?;
let processed =
@@ -677,6 +672,7 @@ fn accepted_fetch_event_receipt(
}
}
+#[cfg_attr(coverage_nightly, coverage(off))]
fn relay_fetch_event_matches_filters(
filters: &[RadrootsNostrFilter],
event: &RadrootsNostrEvent,
@@ -691,6 +687,7 @@ fn relay_fetch_event_matches_filters(
pub struct RadrootsNostrClientFetchAdapter;
impl RadrootsRelayFetchAdapter for RadrootsNostrClientFetchAdapter {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn fetch<'a>(
&'a self,
request: RadrootsRelayFetchRequest,
@@ -699,6 +696,7 @@ impl RadrootsRelayFetchAdapter for RadrootsNostrClientFetchAdapter {
}
}
+#[cfg_attr(coverage_nightly, coverage(off))]
async fn fetch_from_nostr_relays(
request: RadrootsRelayFetchRequest,
) -> Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError> {
@@ -787,6 +785,7 @@ impl RadrootsMockRelayFetchAdapter {
}
impl RadrootsRelayFetchAdapter for RadrootsMockRelayFetchAdapter {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn fetch<'a>(
&'a self,
_request: RadrootsRelayFetchRequest,
@@ -799,3 +798,57 @@ impl RadrootsRelayFetchAdapter for RadrootsMockRelayFetchAdapter {
fn fetch_item_lock_error<T>(_error: PoisonError<T>) -> RadrootsRelayTransportError {
RadrootsRelayTransportError::Transport("fetch item lock poisoned".to_owned())
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ RadrootsNostrEvent, relay_fetch_event_matches_filters, summarize_nostr_output_failures,
+ };
+ use nostr::JsonUtil;
+ use radroots_nostr::prelude::{
+ RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey,
+ radroots_nostr_build_event,
+ };
+ use std::collections::HashMap;
+
+ const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+
+ fn signed_raw_event() -> RadrootsNostrEvent {
+ let secret_key =
+ RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let event = radroots_nostr_build_event(1, "hello", Vec::new())
+ .expect("event builder")
+ .sign_with_keys(&keys)
+ .expect("signed event");
+ RadrootsNostrEvent::from_json(event.as_json().as_str()).expect("raw event")
+ }
+
+ #[test]
+ fn relay_fetch_filter_helper_rejects_empty_filter_set() {
+ let event = signed_raw_event();
+ assert!(!relay_fetch_event_matches_filters(&[], &event));
+ assert!(relay_fetch_event_matches_filters(
+ &[RadrootsNostrFilter::new().kind(RadrootsNostrKind::TextNote)],
+ &event
+ ));
+ }
+
+ #[test]
+ fn nostr_output_failure_summary_covers_empty_and_reported_failures() {
+ assert_eq!(
+ summarize_nostr_output_failures::<String, String>(&HashMap::new()),
+ "no relay acknowledged the operation"
+ );
+
+ let mut failures = HashMap::new();
+ failures.insert("wss://relay.example.com".to_owned(), "timeout".to_owned());
+ failures.insert("wss://relay-2.example.com".to_owned(), "denied".to_owned());
+
+ let summary = summarize_nostr_output_failures(&failures);
+ assert!(summary.contains("wss://relay.example.com: timeout"));
+ assert!(summary.contains("wss://relay-2.example.com: denied"));
+ assert!(summary.contains("; "));
+ }
+}
diff --git a/crates/relay_transport/src/publish.rs b/crates/relay_transport/src/publish.rs
@@ -367,3 +367,78 @@ fn ensure_raw_event_matches_signed_event(
}
Ok(())
}
+
+#[cfg(all(test, feature = "client"))]
+mod tests {
+ use super::{RadrootsNostrEvent, ensure_raw_event_matches_signed_event};
+ use nostr::JsonUtil;
+ use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent};
+ use radroots_events::kinds::KIND_POST;
+ use radroots_nostr::prelude::{
+ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft,
+ };
+
+ const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+ const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+
+ fn signed_post(content: &str) -> (RadrootsNostrEvent, RadrootsSignedNostrEvent) {
+ let secret_key =
+ RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ let draft = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ 1_700_000_000,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ content,
+ FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ )
+ .expect("draft");
+ let signed_event = radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed event");
+ let raw_event =
+ RadrootsNostrEvent::from_json(signed_event.raw_json.as_str()).expect("raw event");
+ (raw_event, signed_event)
+ }
+
+ fn assert_mismatch(raw_event: &RadrootsNostrEvent, signed_event: RadrootsSignedNostrEvent) {
+ assert!(ensure_raw_event_matches_signed_event(raw_event, &signed_event).is_err());
+ }
+
+ #[test]
+ fn raw_event_match_guard_accepts_exact_event_and_rejects_field_mismatches() {
+ let (raw_event, signed_event) = signed_post("matched");
+ ensure_raw_event_matches_signed_event(&raw_event, &signed_event).expect("matching event");
+
+ let mut mismatched = signed_event.clone();
+ mismatched.id = "00".repeat(32);
+ assert_mismatch(&raw_event, mismatched);
+
+ let mut mismatched = signed_event.clone();
+ mismatched.pubkey = "11".repeat(32);
+ assert_mismatch(&raw_event, mismatched);
+
+ let mut mismatched = signed_event.clone();
+ mismatched.created_at += 1;
+ assert_mismatch(&raw_event, mismatched);
+
+ let mut mismatched = signed_event.clone();
+ mismatched.kind += 1;
+ assert_mismatch(&raw_event, mismatched);
+
+ let mut mismatched = signed_event.clone();
+ mismatched.content.push_str(" changed");
+ assert_mismatch(&raw_event, mismatched);
+
+ let mut mismatched = signed_event.clone();
+ mismatched.sig = "22".repeat(64);
+ assert_mismatch(&raw_event, mismatched);
+
+ let mut mismatched = signed_event;
+ mismatched
+ .tags
+ .push(vec!["t".to_owned(), "compost".to_owned()]);
+ assert_mismatch(&raw_event, mismatched);
+ }
+}
diff --git a/crates/relay_transport/src/relay.rs b/crates/relay_transport/src/relay.rs
@@ -250,3 +250,124 @@ impl RadrootsRelayTargetSet {
self.relays.is_empty()
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ RadrootsRelayUrlPolicy, forbidden_public_ipv4_reason, forbidden_public_ipv6_reason,
+ validate_host_destination,
+ };
+ use std::net::{Ipv4Addr, Ipv6Addr};
+
+ #[test]
+ fn host_destination_validation_covers_public_and_local_policy_edges() {
+ assert!(!RadrootsRelayUrlPolicy::Public.accepts_ws_host("localhost"));
+ assert!(RadrootsRelayUrlPolicy::Localhost.accepts_ws_host("localhost"));
+ validate_host_destination(
+ "wss://93.184.216.34",
+ "93.184.216.34",
+ RadrootsRelayUrlPolicy::Public,
+ )
+ .expect("public ipv4 host");
+ validate_host_destination(
+ "wss://relay.example.com",
+ "relay.example.com",
+ RadrootsRelayUrlPolicy::Public,
+ )
+ .expect("public dns host");
+ validate_host_destination(
+ "ws://127.0.0.1",
+ "127.0.0.1",
+ RadrootsRelayUrlPolicy::Localhost,
+ )
+ .expect("localhost policy host");
+ }
+
+ #[test]
+ fn public_ipv4_classifier_covers_forbidden_ranges_and_global_addresses() {
+ let cases = [
+ Ipv4Addr::new(0, 0, 0, 0),
+ Ipv4Addr::new(0, 1, 2, 3),
+ Ipv4Addr::new(127, 0, 0, 1),
+ Ipv4Addr::new(10, 1, 2, 3),
+ Ipv4Addr::new(169, 254, 1, 2),
+ Ipv4Addr::new(224, 0, 0, 1),
+ Ipv4Addr::new(255, 255, 255, 255),
+ Ipv4Addr::new(192, 0, 2, 1),
+ Ipv4Addr::new(100, 64, 0, 1),
+ Ipv4Addr::new(192, 0, 0, 8),
+ Ipv4Addr::new(198, 18, 0, 1),
+ Ipv4Addr::new(240, 0, 0, 1),
+ ];
+ for address in cases {
+ assert!(forbidden_public_ipv4_reason(address).is_some());
+ }
+ assert_eq!(
+ forbidden_public_ipv4_reason(Ipv4Addr::new(93, 184, 216, 34)),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv4_reason(Ipv4Addr::new(100, 128, 0, 1)),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv4_reason(Ipv4Addr::new(193, 0, 0, 8)),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv4_reason(Ipv4Addr::new(192, 1, 0, 8)),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv4_reason(Ipv4Addr::new(192, 0, 1, 8)),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv4_reason(Ipv4Addr::new(198, 20, 0, 1)),
+ None
+ );
+ }
+
+ #[test]
+ fn public_ipv6_classifier_covers_forbidden_ranges_and_global_addresses() {
+ let cases = [
+ "::ffff:192.168.1.10",
+ "::",
+ "::1",
+ "ff02::1",
+ "fd00::1",
+ "fe80::1",
+ "2001:db8::1",
+ "2001:1::1",
+ ];
+ for address in cases {
+ assert!(
+ forbidden_public_ipv6_reason(address.parse::<Ipv6Addr>().expect("ipv6")).is_some()
+ );
+ }
+ assert_eq!(
+ forbidden_public_ipv6_reason(
+ "2001:4860:4860::8888"
+ .parse::<Ipv6Addr>()
+ .expect("public ipv6")
+ ),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv6_reason("2002:db8::1".parse::<Ipv6Addr>().expect("ipv6")),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv6_reason("2001:db9::1".parse::<Ipv6Addr>().expect("ipv6")),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv6_reason("2002:1::1".parse::<Ipv6Addr>().expect("ipv6")),
+ None
+ );
+ assert_eq!(
+ forbidden_public_ipv6_reason("2001:200::1".parse::<Ipv6Addr>().expect("ipv6")),
+ None
+ );
+ }
+}
diff --git a/crates/relay_transport/tests/transport.rs b/crates/relay_transport/tests/transport.rs
@@ -14,14 +14,14 @@ use radroots_outbox::{
};
use radroots_relay_transport::{
RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsOutboxPublishPolicy,
- RadrootsRelayFetchItem, RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest,
- RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter,
- RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet,
- RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy,
- fetch_and_ingest_relay_events, fetch_relay_events, publish_claimed_outbox_event,
- publish_signed_event,
+ RadrootsRelayFetchFilters, RadrootsRelayFetchItem, RadrootsRelayFetchMode,
+ RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest, RadrootsRelayOutcome,
+ RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt,
+ RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayTransportError,
+ RadrootsRelayUrl, RadrootsRelayUrlPolicy, fetch_and_ingest_relay_events, fetch_relay_events,
+ fetch_relay_events_blocking, publish_claimed_outbox_event, publish_signed_event,
};
-use std::net::{IpAddr, Ipv4Addr};
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
"10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
@@ -230,6 +230,27 @@ fn relay_url_validation_and_target_normalization() {
RadrootsRelayUrl::parse("wss://[fd00::1]", RadrootsRelayUrlPolicy::Public),
Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. })
));
+ for relay_url in [
+ "wss://0.0.0.0",
+ "wss://169.254.1.2",
+ "wss://224.0.0.1",
+ "wss://255.255.255.255",
+ "wss://100.64.0.1",
+ "wss://192.0.0.8",
+ "wss://198.18.0.1",
+ "wss://240.0.0.1",
+ "wss://[::]",
+ "wss://[ff02::1]",
+ "wss://[fe80::1]",
+ "wss://[2001:db8::1]",
+ "wss://[2001:1::1]",
+ "wss://[::ffff:192.168.1.10]",
+ ] {
+ assert!(matches!(
+ RadrootsRelayUrl::parse(relay_url, RadrootsRelayUrlPolicy::Public),
+ Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. })
+ ));
+ }
let public_relay =
RadrootsRelayUrl::parse("wss://relay.example.com", RadrootsRelayUrlPolicy::Public)
.expect("public relay");
@@ -241,6 +262,24 @@ fn relay_url_validation_and_target_normalization() {
.validate_public_resolved_ip_addrs([IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))]),
Err(RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { .. })
));
+ assert!(matches!(
+ public_relay.validate_public_resolved_ip_addrs([IpAddr::V6(
+ "::ffff:192.168.1.10"
+ .parse::<Ipv6Addr>()
+ .expect("mapped ipv6")
+ )]),
+ Err(RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { .. })
+ ));
+ public_relay
+ .validate_public_resolved_ip_addrs([IpAddr::V6(
+ "2001:4860:4860::8888"
+ .parse::<Ipv6Addr>()
+ .expect("public ipv6"),
+ )])
+ .expect("public resolved ipv6");
+ public_relay
+ .validate_public_resolved_ip_addrs(Vec::<IpAddr>::new())
+ .expect("empty resolved set");
assert!(
RadrootsRelayUrl::parse("https://relay.example.com", RadrootsRelayUrlPolicy::Public)
@@ -491,6 +530,10 @@ fn fetch_requests_reject_empty_filter_sets() {
#[test]
fn fetch_requests_reject_zero_limits_and_timeouts() {
let filter = post_relay_fetch_filter(1);
+ let filters = RadrootsRelayFetchFilters::new([filter.clone()]).expect("filters");
+ let as_ref_filters: &[RadrootsNostrFilter] = filters.as_ref();
+ assert_eq!(as_ref_filters.len(), 1);
+
assert!(matches!(
RadrootsRelayFetchRequest::fetch(1_000, 0, [filter.clone()]),
Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "max_events"
@@ -518,6 +561,47 @@ fn fetch_requests_reject_zero_limits_and_timeouts() {
.expect("minimum raw scan limit");
assert_eq!(request.timeout_ms(), 1);
assert_eq!(request.max_raw_events(), 1);
+
+ let request = RadrootsRelayFetchRequest::subscription(1_005, 2, [post_relay_fetch_filter(2)])
+ .expect("subscription request")
+ .with_relay_urls([RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS])
+ .with_timeout_ms(25)
+ .expect("timeout")
+ .with_raw_event_scan_limit(3)
+ .expect("raw limit");
+ assert_eq!(request.mode(), RadrootsRelayFetchMode::Subscription);
+ assert_eq!(request.observed_at_ms(), 1_005);
+ assert_eq!(request.max_events(), 2);
+ assert_eq!(request.max_raw_events(), 3);
+ assert_eq!(
+ request.relay_urls(),
+ &[RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()]
+ );
+ assert_eq!(request.filters().len(), 1);
+ assert_eq!(request.timeout_ms(), 25);
+}
+
+#[test]
+fn fetch_blocking_facade_runs_mock_adapter() {
+ let signed = signed_post("blocking fetch");
+ let accepted_id = signed.id.clone();
+ let adapter = RadrootsMockRelayFetchAdapter::new(vec![
+ RadrootsRelayFetchItem::Event {
+ relay_url: RELAY_PRIMARY_WSS.to_owned(),
+ raw_json: signed.raw_json,
+ observed_at_ms: 1_090,
+ },
+ RadrootsRelayFetchItem::Eose {
+ relay_url: RELAY_PRIMARY_WSS.to_owned(),
+ },
+ ]);
+
+ let receipt = fetch_relay_events_blocking(&adapter, post_relay_fetch_request(1_090, 10))
+ .expect("blocking fetch");
+
+ assert_eq!(receipt.events.len(), 1);
+ assert_eq!(receipt.events[0].event.id.to_hex(), accepted_id);
+ assert_eq!(receipt.connected_relays, vec![RELAY_PRIMARY_WSS]);
}
#[tokio::test]
diff --git a/crates/replica_db/Cargo.toml b/crates/replica_db/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Replica database layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_replica_db"
readme = "README"
[lib]
diff --git a/crates/replica_db_schema/Cargo.toml b/crates/replica_db_schema/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Replica schema model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_replica_db_schema"
readme = "README"
[lib]
diff --git a/crates/replica_sync/Cargo.toml b/crates/replica_sync/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Replica synchronization layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_replica_sync"
readme = "README"
[lib]
diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Runtime configuration model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_runtime"
readme = "README"
[features]
diff --git a/crates/runtime_distribution/Cargo.toml b/crates/runtime_distribution/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Runtime distribution model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_runtime_distribution"
readme = "README"
[dependencies]
diff --git a/crates/runtime_manager/Cargo.toml b/crates/runtime_manager/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Runtime lifecycle layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_runtime_manager"
readme = "README"
[dependencies]
diff --git a/crates/runtime_paths/Cargo.toml b/crates/runtime_paths/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Runtime path model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_runtime_paths"
readme = "README"
[dependencies]
diff --git a/crates/secret_vault/Cargo.toml b/crates/secret_vault/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Secret storage model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_secret_vault"
readme = "README"
[features]
diff --git a/crates/secret_vault/src/selection.rs b/crates/secret_vault/src/selection.rs
@@ -126,6 +126,29 @@ mod tests {
}
#[test]
+ fn encrypted_file_resolves_when_available() {
+ let selection = RadrootsSecretBackendSelection {
+ primary: RadrootsSecretBackend::EncryptedFile,
+ };
+
+ let resolved = selection
+ .resolve(RadrootsSecretBackendAvailability {
+ host_vault: RadrootsHostVaultCapabilities::unavailable(),
+ encrypted_file: true,
+ external_command: false,
+ memory: false,
+ })
+ .expect("encrypted file resolves");
+
+ assert_eq!(
+ resolved,
+ RadrootsResolvedSecretBackend {
+ backend: RadrootsSecretBackend::EncryptedFile,
+ }
+ );
+ }
+
+ #[test]
fn unsupported_host_vault_policy_fails_closed() {
let selection = RadrootsSecretBackendSelection {
primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy {
diff --git a/crates/sql_core/Cargo.toml b/crates/sql_core/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "SQL support layer for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_sql_core"
readme = "README"
[lib]
diff --git a/crates/test_fixtures/src/knowledge.rs b/crates/test_fixtures/src/knowledge.rs
@@ -45,7 +45,7 @@ pub struct RadrootsKnowledgeAdversarialFixture {
pub expected_error_code: &'static str,
}
-pub const RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES: [RadrootsKnowledgeAdversarialFixture; 8] = [
+pub const RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES: [RadrootsKnowledgeAdversarialFixture; 13] = [
RadrootsKnowledgeAdversarialFixture {
id: "malformed_tags",
pipeline_stage: "contract_validation",
@@ -77,6 +77,31 @@ pub const RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES: [RadrootsKnowledgeAdversarial
expected_error_code: "invalid_field",
},
RadrootsKnowledgeAdversarialFixture {
+ id: "invalid_redirect_target_kind",
+ pipeline_stage: "encode",
+ expected_error_code: "invalid_field",
+ },
+ RadrootsKnowledgeAdversarialFixture {
+ id: "merge_request_missing_source_marker",
+ pipeline_stage: "event_parse",
+ expected_error_code: "invalid_tag",
+ },
+ RadrootsKnowledgeAdversarialFixture {
+ id: "merge_request_json_content_guard",
+ pipeline_stage: "wire_shape",
+ expected_error_code: "plain_text_content",
+ },
+ RadrootsKnowledgeAdversarialFixture {
+ id: "orphan_fork_marker",
+ pipeline_stage: "event_parse",
+ expected_error_code: "invalid_tag",
+ },
+ RadrootsKnowledgeAdversarialFixture {
+ id: "orphan_defer_marker",
+ pipeline_stage: "event_parse",
+ expected_error_code: "invalid_tag",
+ },
+ RadrootsKnowledgeAdversarialFixture {
id: "id_mismatch",
pipeline_stage: "nip01_verification",
expected_error_code: "id_mismatch",
@@ -125,21 +150,20 @@ pub fn event_ref(character: char, kind: u32) -> RadrootsNostrEventRef {
}
}
-pub fn wiki_article_ref() -> RadrootsNostrEventRef {
- RadrootsNostrEventRef {
- id: hex_64('b'),
- author: hex_64('a'),
+pub fn address_ref() -> RadrootsAddressableRef {
+ RadrootsAddressableRef {
kind: KIND_WIKI_ARTICLE,
- d_tag: Some("soil-health".to_string()),
- relays: Some(vec![RELAY_PRIMARY_WSS.to_string()]),
+ pubkey: hex_64('a'),
+ d_tag: "soil-health".to_string(),
+ relays: vec![RELAY_PRIMARY_WSS.to_string()],
}
}
-pub fn address_ref() -> RadrootsAddressableRef {
+pub fn deferred_address_ref() -> RadrootsAddressableRef {
RadrootsAddressableRef {
kind: KIND_WIKI_ARTICLE,
pubkey: hex_64('a'),
- d_tag: "soil-health".to_string(),
+ d_tag: "soil-health-v2".to_string(),
relays: vec![RELAY_PRIMARY_WSS.to_string()],
}
}
@@ -151,6 +175,13 @@ pub fn wiki_article_version_ref() -> RadrootsWikiArticleVersionRef {
}
}
+pub fn wiki_article_deferred_version_ref() -> RadrootsWikiArticleVersionRef {
+ RadrootsWikiArticleVersionRef {
+ event_id: hex_64('c'),
+ address_ref: deferred_address_ref(),
+ }
+}
+
pub fn wiki_article() -> RadrootsWikiArticle {
RadrootsWikiArticle {
d_tag: "soil-health".to_string(),
@@ -160,8 +191,8 @@ pub fn wiki_article() -> RadrootsWikiArticle {
summary: Some("Living soil basics".to_string()),
topics: vec!["soil".to_string(), "local-food".to_string()],
references: vec![event_ref('2', KIND_KNOWLEDGE_SOURCE)],
- forked_from: Vec::new(),
- deferred_to: None,
+ forked_from: vec![wiki_article_version_ref()],
+ deferred_to: Some(wiki_article_deferred_version_ref()),
}
}
@@ -182,6 +213,13 @@ pub fn wiki_merge_request() -> RadrootsWikiMergeRequest {
}
}
+pub fn wiki_merge_request_without_base_version() -> RadrootsWikiMergeRequest {
+ RadrootsWikiMergeRequest {
+ base_version_event_id: None,
+ ..wiki_merge_request()
+ }
+}
+
pub fn knowledge_source() -> RadrootsKnowledgeSource {
RadrootsKnowledgeSource {
schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(),
@@ -365,6 +403,14 @@ pub fn knowledge_valid_fixtures() -> Vec<RadrootsKnowledgeFixtureCase> {
data: RadrootsKnowledgeFixture::WikiMergeRequest(wiki_merge_request()),
},
RadrootsKnowledgeFixtureCase {
+ id: "wiki_merge_request_without_base_valid",
+ contract_id: "radroots.wiki.merge_request.v1",
+ kind: KIND_WIKI_MERGE_REQUEST,
+ data: RadrootsKnowledgeFixture::WikiMergeRequest(
+ wiki_merge_request_without_base_version(),
+ ),
+ },
+ RadrootsKnowledgeFixtureCase {
id: "knowledge_source_valid",
contract_id: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
kind: KIND_KNOWLEDGE_SOURCE,
@@ -426,7 +472,7 @@ mod tests {
#[test]
fn valid_fixture_catalog_covers_all_contract_ids() {
let fixtures = knowledge_valid_fixtures();
- assert_eq!(fixtures.len(), RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS.len());
+ assert!(fixtures.len() >= RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS.len());
for contract_id in RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS {
assert!(
fixtures
@@ -450,6 +496,11 @@ mod tests {
"private_coordinate_leakage",
"unsupported_contract_shape",
"invalid_nip54_d_tag",
+ "invalid_redirect_target_kind",
+ "merge_request_missing_source_marker",
+ "merge_request_json_content_guard",
+ "orphan_fork_marker",
+ "orphan_defer_marker",
"id_mismatch",
"signature_invalidity",
];
diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
description = "Trade domain model for Radroots"
repository.workspace = true
homepage.workspace = true
+documentation = "https://docs.rs/radroots_trade"
readme = "README"
[features]
diff --git a/crates/trade/src/dvm.rs b/crates/trade/src/dvm.rs
@@ -444,8 +444,7 @@ pub fn build_transition_proof_result_tags(
source,
}
})?;
- let request_json = serde_json::to_string(request_event)
- .map_err(RadrootsTradeDvmError::SerializeRequestEvent)?;
+ let request_json = serialize_request_event(request_event)?;
let mut tags = vec![
vec![RADROOTS_DVM_TAG_REQUEST.to_string(), request_json],
vec![
@@ -495,6 +494,13 @@ pub fn build_transition_proof_result_tags(
Ok(tags)
}
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialize_request_event(
+ request_event: &RadrootsNostrEvent,
+) -> Result<String, RadrootsTradeDvmError> {
+ serde_json::to_string(request_event).map_err(RadrootsTradeDvmError::SerializeRequestEvent)
+}
+
pub fn parse_transition_proof_result_tags(
kind: u32,
tags: &[Vec<String>],
@@ -623,14 +629,19 @@ fn validate_transition_proof_request_content(
});
}
validate_hash32(&content.reducer_program_hash, "reducer_program_hash")?;
- if let Some(previous_state_root) = content.previous_state_root.as_ref() {
- validate_hash32(previous_state_root, "previous_state_root")?;
- }
- if let Some(sp1_program_hash) = content.sp1_program_hash.as_ref() {
- validate_hash32(sp1_program_hash, "sp1_program_hash")?;
- }
- if let Some(sp1_verifying_key_hash) = content.sp1_verifying_key_hash.as_ref() {
- validate_hash32(sp1_verifying_key_hash, "sp1_verifying_key_hash")?;
+ validate_optional_hash32(&content.previous_state_root, "previous_state_root")?;
+ validate_optional_hash32(&content.sp1_program_hash, "sp1_program_hash")?;
+ validate_optional_hash32(&content.sp1_verifying_key_hash, "sp1_verifying_key_hash")?;
+ Ok(())
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn validate_optional_hash32(
+ value: &Option<String>,
+ field: &'static str,
+) -> Result<(), RadrootsTradeDvmError> {
+ if let Some(value) = value.as_ref() {
+ validate_hash32(value, field)?;
}
Ok(())
}
@@ -747,23 +758,28 @@ fn parse_listing_addr_tag(
}
fn validate_hash32(value: &str, field: &'static str) -> Result<(), RadrootsTradeDvmError> {
- let Some(hex) = value.strip_prefix("0x") else {
+ if !is_hash32(value) {
return Err(RadrootsTradeDvmError::InvalidHash { field });
+ }
+ Ok(())
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn is_hash32(value: &str) -> bool {
+ let Some(hex) = value.strip_prefix("0x") else {
+ return false;
};
- if hex.len() != 64
- || !hex
+ hex.len() == 64
+ && hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return Err(RadrootsTradeDvmError::InvalidHash { field });
- }
- Ok(())
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
- use radroots_events::kinds::KIND_LISTING;
+ use radroots_events::kinds::{KIND_LISTING, KIND_PROFILE};
const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -931,4 +947,512 @@ mod tests {
Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I })
));
}
+
+ #[test]
+ fn dvm_label_helpers_cover_all_variants_and_unknown_values() {
+ for (mode, label) in [
+ (RadrootsTradeProofMode::None, "none"),
+ (RadrootsTradeProofMode::Core, "core"),
+ (RadrootsTradeProofMode::Compressed, "compressed"),
+ (RadrootsTradeProofMode::Groth16, "groth16"),
+ (RadrootsTradeProofMode::Plonk, "plonk"),
+ ] {
+ assert_eq!(mode.as_str(), label);
+ assert_eq!(RadrootsTradeProofMode::parse(label).unwrap(), mode);
+ }
+ assert!(matches!(
+ RadrootsTradeProofMode::parse("stark"),
+ Err(RadrootsTradeDvmError::InvalidProofMode { value }) if value == "stark"
+ ));
+
+ for (role, label) in [
+ (RadrootsTradeDvmInputRole::Listing, "radroots:listing_event"),
+ (
+ RadrootsTradeDvmInputRole::OrderRequest,
+ "radroots:order_request_event",
+ ),
+ (
+ RadrootsTradeDvmInputRole::OrderDecision,
+ "radroots:order_decision_event",
+ ),
+ ] {
+ assert_eq!(role.as_str(), label);
+ assert_eq!(RadrootsTradeDvmInputRole::parse(label).unwrap(), role);
+ }
+ assert!(matches!(
+ RadrootsTradeDvmInputRole::parse("radroots:legacy"),
+ Err(RadrootsTradeDvmError::InvalidInputRole { value }) if value == "radroots:legacy"
+ ));
+
+ for (status, label) in [
+ (
+ RadrootsTradeDvmFeedbackStatus::PaymentRequired,
+ "payment-required",
+ ),
+ (RadrootsTradeDvmFeedbackStatus::Processing, "processing"),
+ (RadrootsTradeDvmFeedbackStatus::Error, "error"),
+ (RadrootsTradeDvmFeedbackStatus::Success, "success"),
+ (RadrootsTradeDvmFeedbackStatus::Partial, "partial"),
+ ] {
+ assert_eq!(status.as_str(), label);
+ assert_eq!(
+ RadrootsTradeDvmFeedbackStatus::parse(label).unwrap(),
+ status
+ );
+ }
+ assert!(matches!(
+ RadrootsTradeDvmFeedbackStatus::parse("queued"),
+ Err(RadrootsTradeDvmError::InvalidTag {
+ tag: TAG_STATUS,
+ value,
+ ..
+ }) if value == "queued"
+ ));
+ }
+
+ #[test]
+ fn transition_proof_request_parser_rejects_kind_tag_content_and_hash_edges() {
+ let content = request_content();
+ let mut event = request_event(&content);
+ event.kind = KIND_PROFILE;
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::UnsupportedKind {
+ expected: KIND_TRADE_TRANSITION_PROOF_REQUEST,
+ actual: KIND_PROFILE
+ })
+ ));
+
+ let mut event = request_event(&content);
+ event.content = "{".to_string();
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidContent(_))
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.iter_mut().for_each(|tag| {
+ if tag.first().map(String::as_str) == Some(TAG_P) {
+ tag[1] = "bad".to_string();
+ }
+ });
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_P, .. })
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.iter_mut().for_each(|tag| {
+ if tag.first().map(String::as_str) == Some(TAG_A) {
+ tag[1] = "bad".to_string();
+ }
+ });
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_A, .. })
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.iter_mut().for_each(|tag| {
+ if tag.first().map(String::as_str) == Some(TAG_A) {
+ tag[1] = format!("{KIND_LISTING}:{SELLER}:BBBBBBBBBBBBBBBBBBBBBg");
+ }
+ });
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::ContentMismatch {
+ field: "request.listing_addr"
+ })
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.iter_mut().for_each(|tag| {
+ if tag.first().map(String::as_str) == Some(TAG_I)
+ && tag.get(3).map(String::as_str)
+ == Some(RadrootsTradeDvmInputRole::Listing.as_str())
+ {
+ tag[1] = event_id(99).into_string();
+ }
+ });
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::ContentMismatch {
+ field: "radroots:listing_event"
+ })
+ ));
+
+ for (bad_content, field) in [
+ {
+ let mut bad = content.clone();
+ bad.decision.order_id = RadrootsOrderId::parse("order-2").unwrap();
+ (bad, "order_id")
+ },
+ {
+ let mut bad = content.clone();
+ bad.decision.listing_addr = RadrootsListingAddress::parse(format!(
+ "{KIND_LISTING}:{SELLER}:BBBBBBBBBBBBBBBBBBBBBg"
+ ))
+ .unwrap();
+ (bad, "listing_addr")
+ },
+ {
+ let mut bad = content.clone();
+ bad.decision.buyer_pubkey = public_key(WORKER);
+ (bad, "buyer_pubkey")
+ },
+ {
+ let mut bad = content.clone();
+ bad.decision.seller_pubkey = public_key(WORKER);
+ (bad, "seller_pubkey")
+ },
+ ] {
+ let event = request_event(&bad_content);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::ContentMismatch { field: got }) if got == field
+ ));
+ }
+
+ for (bad_content, field) in [
+ {
+ let mut bad = content.clone();
+ bad.reducer_program_hash = "bad".to_string();
+ (bad, "reducer_program_hash")
+ },
+ {
+ let mut bad = content.clone();
+ bad.previous_state_root = Some("bad".to_string());
+ (bad, "previous_state_root")
+ },
+ {
+ let mut bad = content.clone();
+ bad.sp1_program_hash = Some("bad".to_string());
+ (bad, "sp1_program_hash")
+ },
+ {
+ let mut bad = content.clone();
+ bad.sp1_verifying_key_hash = Some("bad".to_string());
+ (bad, "sp1_verifying_key_hash")
+ },
+ ] {
+ let event = request_event(&bad_content);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidHash { field: got }) if got == field
+ ));
+ }
+
+ let mut event = request_event(&content);
+ event.tags.push(vec![TAG_I.to_string()]);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I })
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.push(vec![
+ TAG_I.to_string(),
+ event_id(44).into_string(),
+ "url".to_string(),
+ RadrootsTradeDvmInputRole::Listing.as_str().to_string(),
+ ]);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_I, .. })
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.push(vec![
+ TAG_I.to_string(),
+ event_id(44).into_string(),
+ RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(),
+ ]);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I })
+ ));
+
+ let mut event = request_event(&content);
+ event.tags.push(vec![
+ TAG_I.to_string(),
+ event_id(44).into_string(),
+ RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(),
+ "radroots:legacy_event".to_string(),
+ ]);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidInputRole { .. })
+ ));
+ }
+
+ #[test]
+ fn transition_proof_request_parser_covers_required_tag_and_input_mismatch_edges() {
+ let content = request_content();
+ let tags = build_transition_proof_request_tags(&public_key(WORKER), &content);
+
+ for required in [TAG_P, TAG_A] {
+ let missing = tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) != Some(required))
+ .cloned()
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ parse_transition_proof_request_tags(&missing),
+ Err(RadrootsTradeDvmError::MissingTag { tag }) if tag == required
+ ));
+ }
+
+ for (role, field, event_id) in [
+ (
+ RadrootsTradeDvmInputRole::OrderRequest,
+ "radroots:order_request_event",
+ event_id(98),
+ ),
+ (
+ RadrootsTradeDvmInputRole::OrderDecision,
+ "radroots:order_decision_event",
+ event_id(97),
+ ),
+ ] {
+ let mut event = request_event(&content);
+ event.tags.iter_mut().for_each(|tag| {
+ if tag.first().map(String::as_str) == Some(TAG_I)
+ && tag.get(3).map(String::as_str) == Some(role.as_str())
+ {
+ tag[1] = event_id.clone().into_string();
+ }
+ });
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::ContentMismatch { field: got }) if got == field
+ ));
+ }
+
+ let mut event = request_event(&content);
+ event.tags.push(vec![
+ TAG_I.to_string(),
+ "bad".to_string(),
+ RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(),
+ RadrootsTradeDvmInputRole::Listing.as_str().to_string(),
+ ]);
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_I, .. })
+ ));
+
+ let mut uppercase_hash = content.clone();
+ uppercase_hash.reducer_program_hash = format!("0x{}", "A".repeat(64));
+ assert!(matches!(
+ parse_transition_proof_request_event(&request_event(&uppercase_hash)),
+ Err(RadrootsTradeDvmError::InvalidHash {
+ field: "reducer_program_hash"
+ })
+ ));
+ }
+
+ #[test]
+ fn transition_proof_result_and_feedback_parsers_cover_error_edges() {
+ let content = request_content();
+ let request_event = request_event(&content);
+ let request_tags =
+ parse_transition_proof_request_tags(&request_event.tags).expect("request tags");
+ let binding = RadrootsTradeTransitionProofResultBinding {
+ listing_event_id: content.listing_event_id.clone(),
+ root_event_id: content.request_event_id.clone(),
+ target_event_id: content.decision_event_id.clone(),
+ validation_receipt_event_id: None,
+ };
+ let tags = build_transition_proof_result_tags(
+ &request_event,
+ &public_key(BUYER),
+ &request_tags.inputs,
+ &binding,
+ )
+ .expect("result tags without receipt");
+ let parsed = parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &tags)
+ .expect("result tags without receipt");
+ assert_eq!(parsed.binding.validation_receipt_event_id, None);
+
+ for required in [
+ RADROOTS_DVM_TAG_REQUEST,
+ TAG_E,
+ TAG_P,
+ RADROOTS_DVM_TAG_LISTING_EVENT,
+ RADROOTS_DVM_TAG_ROOT_EVENT,
+ RADROOTS_DVM_TAG_TARGET_EVENT,
+ ] {
+ let missing = tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) != Some(required))
+ .cloned()
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &missing),
+ Err(RadrootsTradeDvmError::MissingTag { tag }) if tag == required
+ ));
+ }
+
+ assert!(matches!(
+ parse_transition_proof_result_tags(KIND_PROFILE, &tags),
+ Err(RadrootsTradeDvmError::UnsupportedKind {
+ expected: KIND_TRADE_TRANSITION_PROOF_RESULT,
+ actual: KIND_PROFILE
+ })
+ ));
+
+ let mut bad_request_event = request_event.clone();
+ bad_request_event.id = "bad".to_string();
+ assert!(matches!(
+ build_transition_proof_result_tags(
+ &bad_request_event,
+ &public_key(BUYER),
+ &request_tags.inputs,
+ &binding,
+ ),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_E, .. })
+ ));
+
+ let mut invalid_json = tags.clone();
+ invalid_json[0][1] = "{".to_string();
+ assert!(matches!(
+ parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &invalid_json),
+ Err(RadrootsTradeDvmError::InvalidRequestEvent(_))
+ ));
+
+ let mut wrong_kind_request = request_event.clone();
+ wrong_kind_request.kind = KIND_PROFILE;
+ let mut wrong_kind = tags.clone();
+ wrong_kind[0][1] = serde_json::to_string(&wrong_kind_request).unwrap();
+ assert!(matches!(
+ parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &wrong_kind),
+ Err(RadrootsTradeDvmError::RequestEventKind { kind: KIND_PROFILE })
+ ));
+
+ let mut mismatched_request_id = tags.clone();
+ let request_id_tag = mismatched_request_id
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_E))
+ .expect("request e tag");
+ request_id_tag[1] = event_id(99).into_string();
+ assert!(matches!(
+ parse_transition_proof_result_tags(
+ KIND_TRADE_TRANSITION_PROOF_RESULT,
+ &mismatched_request_id,
+ ),
+ Err(RadrootsTradeDvmError::RequestEventIdMismatch)
+ ));
+
+ let mut invalid_customer = tags.clone();
+ let customer_tag = invalid_customer
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_P))
+ .expect("customer p tag");
+ customer_tag[1] = "bad".to_string();
+ assert!(matches!(
+ parse_transition_proof_result_tags(
+ KIND_TRADE_TRANSITION_PROOF_RESULT,
+ &invalid_customer,
+ ),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_P, .. })
+ ));
+
+ for required in [
+ RADROOTS_DVM_TAG_LISTING_EVENT,
+ RADROOTS_DVM_TAG_ROOT_EVENT,
+ RADROOTS_DVM_TAG_TARGET_EVENT,
+ ] {
+ let mut invalid_event_tag = tags.clone();
+ let tag = invalid_event_tag
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(required))
+ .expect("event tag");
+ tag[1] = "bad".to_string();
+ assert!(matches!(
+ parse_transition_proof_result_tags(
+ KIND_TRADE_TRANSITION_PROOF_RESULT,
+ &invalid_event_tag,
+ ),
+ Err(RadrootsTradeDvmError::InvalidTag { tag, .. }) if tag == required
+ ));
+ }
+
+ let mut invalid_input_event = tags.clone();
+ let input_tag = invalid_input_event
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_I))
+ .expect("input tag");
+ input_tag[1] = "bad".to_string();
+ assert!(matches!(
+ parse_transition_proof_result_tags(
+ KIND_TRADE_TRANSITION_PROOF_RESULT,
+ &invalid_input_event,
+ ),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_I, .. })
+ ));
+
+ let mut invalid_receipt = tags.clone();
+ invalid_receipt.push(vec![
+ RADROOTS_DVM_TAG_VALIDATION_RECEIPT.to_string(),
+ "bad".to_string(),
+ ]);
+ assert!(matches!(
+ parse_transition_proof_result_tags(
+ KIND_TRADE_TRANSITION_PROOF_RESULT,
+ &invalid_receipt
+ ),
+ Err(RadrootsTradeDvmError::InvalidTag {
+ tag: RADROOTS_DVM_TAG_VALIDATION_RECEIPT,
+ ..
+ })
+ ));
+
+ assert!(matches!(
+ parse_job_feedback_tags(KIND_PROFILE, &[]),
+ Err(RadrootsTradeDvmError::UnsupportedKind {
+ expected: KIND_JOB_FEEDBACK,
+ actual: KIND_PROFILE
+ })
+ ));
+
+ for required in [TAG_STATUS, TAG_E, TAG_P] {
+ let missing = build_job_feedback_tags(
+ RadrootsTradeDvmFeedbackStatus::Success,
+ &event_id(10),
+ &public_key(BUYER),
+ )
+ .into_iter()
+ .filter(|tag| tag.first().map(String::as_str) != Some(required))
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ parse_job_feedback_tags(KIND_JOB_FEEDBACK, &missing),
+ Err(RadrootsTradeDvmError::MissingTag { tag }) if tag == required
+ ));
+ }
+
+ let mut bad_feedback = build_job_feedback_tags(
+ RadrootsTradeDvmFeedbackStatus::Success,
+ &event_id(10),
+ &public_key(BUYER),
+ );
+ bad_feedback[0][1] = "queued".to_string();
+ assert!(matches!(
+ parse_job_feedback_tags(KIND_JOB_FEEDBACK, &bad_feedback),
+ Err(RadrootsTradeDvmError::InvalidTag {
+ tag: TAG_STATUS,
+ ..
+ })
+ ));
+ bad_feedback[0][1] = RadrootsTradeDvmFeedbackStatus::Success.as_str().to_string();
+ bad_feedback[1][1] = "bad".to_string();
+ assert!(matches!(
+ parse_job_feedback_tags(KIND_JOB_FEEDBACK, &bad_feedback),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_E, .. })
+ ));
+ bad_feedback[1][1] = event_id(10).into_string();
+ bad_feedback[2][1] = "bad".to_string();
+ assert!(matches!(
+ parse_job_feedback_tags(KIND_JOB_FEEDBACK, &bad_feedback),
+ Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_P, .. })
+ ));
+ }
}
diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs
@@ -133,3 +133,77 @@ impl RadrootsTradeLocatorCandidate {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_events::kinds::KIND_LISTING;
+
+ const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ fn event_id(raw: u8) -> RadrootsEventId {
+ RadrootsEventId::parse(format!("{raw:064x}")).expect("event id")
+ }
+
+ fn order_id() -> RadrootsOrderId {
+ RadrootsOrderId::parse("order-1").expect("order id")
+ }
+
+ fn public_key(raw: &str) -> RadrootsPublicKey {
+ RadrootsPublicKey::parse(raw).expect("public key")
+ }
+
+ fn listing_addr() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
+ .expect("listing address")
+ }
+
+ #[test]
+ fn trade_id_and_locator_accessors_cover_public_surface() {
+ let order_id = order_id();
+ let trade_id = RadrootsTradeId::parse(order_id.as_str()).expect("trade id");
+
+ assert_eq!(trade_id.as_order_id(), &order_id);
+ assert_eq!(trade_id.as_str(), "order-1");
+ assert_eq!(trade_id.as_ref(), "order-1");
+ assert_eq!(RadrootsTradeId::from_str("order-1").unwrap(), trade_id);
+ assert!(RadrootsTradeId::parse(" ").is_err());
+ assert_eq!(
+ RadrootsOrderId::from(trade_id.clone()),
+ trade_id.clone().into_order_id()
+ );
+
+ let locator = RadrootsTradeLocator::from_order_id(order_id.clone())
+ .with_root_event_id(event_id(1))
+ .with_listing_addr(listing_addr())
+ .with_buyer_pubkey(public_key(BUYER))
+ .with_seller_pubkey(public_key(SELLER));
+
+ assert_eq!(locator.order_id(), &order_id);
+ assert_eq!(locator.trade_id.as_order_id(), &order_id);
+ assert_eq!(locator.root_event_id, Some(event_id(1)));
+ assert_eq!(locator.listing_addr, Some(listing_addr()));
+ assert_eq!(locator.buyer_pubkey, Some(public_key(BUYER)));
+ assert_eq!(locator.seller_pubkey, Some(public_key(SELLER)));
+ }
+
+ #[test]
+ fn locator_candidate_converts_to_specific_locator() {
+ let candidate = RadrootsTradeLocatorCandidate {
+ trade_id: order_id().into(),
+ root_event_id: event_id(1),
+ listing_addr: listing_addr(),
+ buyer_pubkey: public_key(BUYER),
+ seller_pubkey: public_key(SELLER),
+ };
+
+ let locator = candidate.locator();
+
+ assert_eq!(locator.trade_id, candidate.trade_id);
+ assert_eq!(locator.root_event_id, Some(candidate.root_event_id));
+ assert_eq!(locator.listing_addr, Some(candidate.listing_addr));
+ assert_eq!(locator.buyer_pubkey, Some(candidate.buyer_pubkey));
+ assert_eq!(locator.seller_pubkey, Some(candidate.seller_pubkey));
+ }
+}
diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs
@@ -204,10 +204,7 @@ fn listing_from_tags(
if !is_d_tag_base64url(&d_tag) {
return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
}
- let d_tag = match RadrootsDTag::parse(&d_tag) {
- Ok(d_tag) => d_tag,
- Err(_) => unreachable!(),
- };
+ let d_tag = RadrootsDTag::parse(&d_tag).expect("base64url d tag parses");
let mut product = RadrootsListingProduct {
key: String::new(),
title: String::new(),
@@ -933,6 +930,107 @@ mod tests {
}
#[test]
+ fn listing_from_event_parts_rejects_private_location_json_fields() {
+ let err =
+ listing_from_event_parts(&base_event_tags(), r#"{"location":{"lat":1}}"#).unwrap_err();
+
+ assert_eq!(parse_error_tag(err), "location".to_string());
+ }
+
+ #[test]
+ fn listing_from_tags_rejects_private_coordinate_and_geohash_edges() {
+ let mut invalid_geohash = base_trade_tags();
+ invalid_geohash.push(vec![
+ TAG_LOCATION.into(),
+ "Farm".into(),
+ "Town".into(),
+ "Region".into(),
+ "US".into(),
+ ]);
+ invalid_geohash.push(vec![TAG_GEOHASH.into(), "9q8yyz".into()]);
+ let err = listing_from_tags(
+ &invalid_geohash,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string());
+
+ let mut missing_geohash_value = base_trade_tags();
+ missing_geohash_value.push(vec![TAG_GEOHASH.into()]);
+ let err = listing_from_tags(
+ &missing_geohash_value,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string());
+
+ let mut duplicate_geohash = base_trade_tags();
+ duplicate_geohash.push(vec![
+ TAG_LOCATION.into(),
+ "Farm".into(),
+ "Town".into(),
+ "Region".into(),
+ "US".into(),
+ ]);
+ duplicate_geohash.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]);
+ duplicate_geohash.push(vec![TAG_GEOHASH.into(), "u6sep".into()]);
+ let err = listing_from_tags(
+ &duplicate_geohash,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string());
+
+ let mut private_coordinates = base_trade_tags();
+ private_coordinates.push(vec![TAG_DD.into(), "1.0".into()]);
+ let err = listing_from_tags(
+ &private_coordinates,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(parse_error_tag(err), TAG_DD.to_string());
+
+ let mut blank_optional_locality = base_trade_tags();
+ blank_optional_locality.push(vec![
+ TAG_LOCATION.into(),
+ "Farm".into(),
+ " ".into(),
+ " ".into(),
+ "US".into(),
+ ]);
+ blank_optional_locality.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]);
+ let listing = listing_from_tags(
+ &blank_optional_locality,
+ listing_d_tag(),
+ farm_ref(),
+ "seller".to_string(),
+ None,
+ None,
+ )
+ .expect("listing with blank optional locality");
+ let location = listing.location.expect("location");
+ assert_eq!(location.city, None);
+ assert_eq!(location.region, None);
+ assert_eq!(location.country.as_deref(), Some("US"));
+ }
+
+ #[test]
fn listing_from_event_parts_uses_json_content_and_backfills_tags() {
let mut listing = parse_base_listing_from_tags();
listing.farm.pubkey = String::new();
diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs
@@ -81,9 +81,7 @@ pub fn parse_listing_address(
.map_err(RadrootsListingAddressError::InvalidAddress)?;
let parts = RadrootsAddressableCoordinateParts::parse(address.as_str())
.map_err(RadrootsListingAddressError::InvalidAddress)?;
- if !is_listing_kind(parts.kind) {
- return Err(RadrootsListingAddressError::InvalidKind { actual: parts.kind });
- }
+ ensure_listing_kind(parts.kind)?;
Ok(RadrootsListingAddressParts {
address,
kind: parts.kind,
@@ -103,9 +101,7 @@ pub fn parse_public_listing_address(
RadrootsPublicListingAddressError::InvalidListingKind { actual }
}
})?;
- if parts.kind != KIND_LISTING {
- return Err(RadrootsPublicListingAddressError::InvalidKind { actual: parts.kind });
- }
+ ensure_public_listing_kind(parts.kind)?;
Ok(RadrootsPublicListingAddress {
address: parts.address,
kind: parts.kind,
@@ -114,6 +110,22 @@ pub fn parse_public_listing_address(
})
}
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> {
+ if !is_listing_kind(kind) {
+ return Err(RadrootsListingAddressError::InvalidKind { actual: kind });
+ }
+ Ok(())
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn ensure_public_listing_kind(kind: u32) -> Result<(), RadrootsPublicListingAddressError> {
+ if kind != KIND_LISTING {
+ return Err(RadrootsPublicListingAddressError::InvalidKind { actual: kind });
+ }
+ Ok(())
+}
+
pub fn parse_listing_event(
event: &RadrootsNostrEvent,
) -> Result<RadrootsListing, ListingParseError> {
@@ -267,6 +279,20 @@ mod tests {
actual: KIND_PROFILE
})
));
+
+ let typed = RadrootsListingAddress::parse(&raw).expect("typed profile address");
+ assert!(matches!(
+ parse_public_listing_address(typed.clone()),
+ Err(RadrootsPublicListingAddressError::InvalidListingKind {
+ actual: KIND_PROFILE
+ })
+ ));
+ assert!(matches!(
+ parse_public_listing_address(&typed),
+ Err(RadrootsPublicListingAddressError::InvalidListingKind {
+ actual: KIND_PROFILE
+ })
+ ));
}
#[test]
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -144,12 +144,7 @@ pub fn validate_listing_event(
) {
return Err(TradeListingValidationError::MissingLocationLocality);
}
- if location.geohash.trim().is_empty() {
- return Err(TradeListingValidationError::MissingLocationGeohash);
- }
- if !is_public_geohash5(&location.geohash) {
- return Err(TradeListingValidationError::InvalidLocationGeohash);
- }
+ validate_listing_location_geohash(&location.geohash)?;
let delivery_method = listing
.delivery_method
.clone()
@@ -174,6 +169,17 @@ pub fn validate_listing_event(
})
}
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn validate_listing_location_geohash(geohash: &str) -> Result<(), TradeListingValidationError> {
+ if geohash.trim().is_empty() {
+ return Err(TradeListingValidationError::MissingLocationGeohash);
+ }
+ if !is_public_geohash5(geohash) {
+ return Err(TradeListingValidationError::InvalidLocationGeohash);
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::{TradeListingValidationError, validate_listing_event};
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -922,18 +922,23 @@ fn trade_locator_candidates(
seller_pubkey: request.payload.seller_pubkey.clone(),
})
.collect::<Vec<_>>();
- candidates.sort_by(|left, right| {
- left.root_event_id
- .cmp(&right.root_event_id)
- .then_with(|| left.trade_id.cmp(&right.trade_id))
- .then_with(|| left.listing_addr.cmp(&right.listing_addr))
- .then_with(|| left.buyer_pubkey.cmp(&right.buyer_pubkey))
- .then_with(|| left.seller_pubkey.cmp(&right.seller_pubkey))
- });
+ candidates.sort_by(trade_locator_candidate_order);
candidates.dedup_by(|left, right| left.root_event_id == right.root_event_id);
candidates
}
+fn trade_locator_candidate_order(
+ left: &RadrootsTradeLocatorCandidate,
+ right: &RadrootsTradeLocatorCandidate,
+) -> core::cmp::Ordering {
+ left.root_event_id
+ .cmp(&right.root_event_id)
+ .then_with(|| left.trade_id.cmp(&right.trade_id))
+ .then_with(|| left.listing_addr.cmp(&right.listing_addr))
+ .then_with(|| left.buyer_pubkey.cmp(&right.buyer_pubkey))
+ .then_with(|| left.seller_pubkey.cmp(&right.seller_pubkey))
+}
+
fn request_matches_trade_locator(
locator: &RadrootsTradeLocator,
request: &RadrootsOrderRequestRecord,
@@ -1379,13 +1384,21 @@ pub fn canonicalize_order_decision_for_signer(
pub fn radroots_order_economics_digest(
economics: &RadrootsOrderEconomics,
) -> Result<String, RadrootsOrderEconomicsDigestError> {
- let encoded = serde_json::to_vec(economics)?;
+ let encoded = serialize_order_economics(economics)?;
let digest = Sha256::digest(encoded);
let mut value = String::from("sha256:");
value.push_str(&hex::encode(digest));
Ok(value)
}
+#[cfg(feature = "serde_json")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialize_order_economics(
+ economics: &RadrootsOrderEconomics,
+) -> Result<Vec<u8>, RadrootsOrderEconomicsDigestError> {
+ serde_json::to_vec(economics).map_err(RadrootsOrderEconomicsDigestError::Serialize)
+}
+
fn cancelled_projection(
order_id: &RadrootsOrderId,
request: &RadrootsOrderRequestRecord,
@@ -2646,7 +2659,7 @@ mod tests {
reduce_order_event_records, reduce_order_event_records_for_trade_locator,
reduce_order_events,
};
- use crate::identity::RadrootsTradeLocator;
+ use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate};
use core::mem::discriminant;
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
@@ -3098,6 +3111,157 @@ mod tests {
));
}
+ #[test]
+ fn trade_locator_reports_missing_and_filters_all_selected_record_families() {
+ let locator = RadrootsTradeLocator::from_order_id(order_id("order-missing"));
+ let missing = reduce_order_event_records_for_trade_locator(
+ &locator,
+ Vec::<RadrootsOrderEventRecord>::new(),
+ );
+ assert!(matches!(
+ missing,
+ RadrootsTradeLocatorProjectionResolution::Missing { .. }
+ ));
+
+ let mut second_request = request_record();
+ second_request.event_id = event_id(9);
+ let mut second_decision = accepted_decision();
+ second_decision.event_id = event_id(10);
+ second_decision.root_event_id = event_id(9);
+ second_decision.prev_event_id = event_id(9);
+ let mut second_proposal = revision_proposal();
+ second_proposal.event_id = event_id(11);
+ second_proposal.root_event_id = event_id(9);
+ second_proposal.prev_event_id = event_id(9);
+ second_proposal.payload.root_event_id = event_id(9);
+ second_proposal.payload.prev_event_id = event_id(9);
+ let mut second_revision_decision = accepted_revision_decision();
+ second_revision_decision.event_id = event_id(12);
+ second_revision_decision.root_event_id = event_id(9);
+ second_revision_decision.prev_event_id = event_id(11);
+ second_revision_decision.payload.root_event_id = event_id(9);
+ second_revision_decision.payload.prev_event_id = event_id(11);
+ let mut second_cancellation = cancellation(event_id(10));
+ second_cancellation.event_id = event_id(13);
+ second_cancellation.root_event_id = event_id(9);
+ let mut wrong_order_request = request_record();
+ wrong_order_request.event_id = event_id(14);
+ wrong_order_request.payload.order_id = order_id("order-2");
+ let mut wrong_order_decision = accepted_decision();
+ wrong_order_decision.event_id = event_id(15);
+ wrong_order_decision.payload.order_id = order_id("order-2");
+ let mut wrong_order_proposal = revision_proposal();
+ wrong_order_proposal.event_id = event_id(16);
+ wrong_order_proposal.payload.order_id = order_id("order-2");
+ let mut wrong_order_revision_decision = accepted_revision_decision();
+ wrong_order_revision_decision.event_id = event_id(17);
+ wrong_order_revision_decision.payload.order_id = order_id("order-2");
+ let mut wrong_order_cancellation = cancellation(event_id(2));
+ wrong_order_cancellation.event_id = event_id(18);
+ wrong_order_cancellation.payload.order_id = order_id("order-2");
+
+ let locator = RadrootsTradeLocator::from_order_id(order_id("order-1"))
+ .with_root_event_id(event_id(1));
+ let resolution = reduce_order_event_records_for_trade_locator(
+ &locator,
+ vec![
+ RadrootsOrderEventRecord::Request(request_record()),
+ RadrootsOrderEventRecord::Request(second_request),
+ RadrootsOrderEventRecord::Request(wrong_order_request),
+ RadrootsOrderEventRecord::Decision(accepted_decision()),
+ RadrootsOrderEventRecord::Decision(second_decision),
+ RadrootsOrderEventRecord::Decision(wrong_order_decision),
+ RadrootsOrderEventRecord::RevisionProposal(revision_proposal()),
+ RadrootsOrderEventRecord::RevisionProposal(second_proposal),
+ RadrootsOrderEventRecord::RevisionProposal(wrong_order_proposal),
+ RadrootsOrderEventRecord::RevisionDecision(accepted_revision_decision()),
+ RadrootsOrderEventRecord::RevisionDecision(second_revision_decision),
+ RadrootsOrderEventRecord::RevisionDecision(wrong_order_revision_decision),
+ RadrootsOrderEventRecord::Cancellation(cancellation(event_id(2))),
+ RadrootsOrderEventRecord::Cancellation(second_cancellation),
+ RadrootsOrderEventRecord::Cancellation(wrong_order_cancellation),
+ ],
+ );
+
+ assert!(matches!(
+ resolution,
+ RadrootsTradeLocatorProjectionResolution::Projected { .. }
+ ));
+ }
+
+ #[test]
+ fn trade_locator_optional_qualifiers_reject_mismatched_request_fields() {
+ for locator in [
+ RadrootsTradeLocator::from_order_id(order_id("order-1"))
+ .with_listing_addr(other_seller_listing_addr()),
+ RadrootsTradeLocator::from_order_id(order_id("order-1"))
+ .with_buyer_pubkey(public_key(OTHER)),
+ RadrootsTradeLocator::from_order_id(order_id("order-1"))
+ .with_seller_pubkey(public_key(OTHER)),
+ ] {
+ let resolution = reduce_order_event_records_for_trade_locator(
+ &locator,
+ vec![RadrootsOrderEventRecord::Request(request_record())],
+ );
+ assert!(matches!(
+ resolution,
+ RadrootsTradeLocatorProjectionResolution::Missing { .. }
+ ));
+ }
+ }
+
+ #[test]
+ fn trade_locator_candidate_order_covers_each_tie_breaker() {
+ let candidate = RadrootsTradeLocatorCandidate {
+ trade_id: order_id("order-1").into(),
+ root_event_id: event_id(1),
+ listing_addr: listing_addr(),
+ buyer_pubkey: public_key(BUYER),
+ seller_pubkey: public_key(SELLER),
+ };
+ let mut right = candidate.clone();
+ right.root_event_id = event_id(2);
+ assert_eq!(
+ super::trade_locator_candidate_order(&candidate, &right),
+ core::cmp::Ordering::Less
+ );
+
+ let mut right = candidate.clone();
+ right.trade_id = order_id("order-2").into();
+ assert_eq!(
+ super::trade_locator_candidate_order(&candidate, &right),
+ core::cmp::Ordering::Less
+ );
+
+ let mut right = candidate.clone();
+ right.listing_addr = other_seller_listing_addr();
+ assert_eq!(
+ super::trade_locator_candidate_order(&candidate, &right),
+ core::cmp::Ordering::Less
+ );
+
+ let mut right = candidate.clone();
+ right.buyer_pubkey = public_key(OTHER);
+ assert_eq!(
+ super::trade_locator_candidate_order(&candidate, &right),
+ core::cmp::Ordering::Less
+ );
+
+ let left = RadrootsTradeLocatorCandidate {
+ trade_id: order_id("order-1").into(),
+ root_event_id: event_id(1),
+ listing_addr: listing_addr(),
+ buyer_pubkey: public_key(BUYER),
+ seller_pubkey: public_key(SELLER),
+ };
+ let mut right = left.clone();
+ right.seller_pubkey = public_key(OTHER);
+ assert_eq!(
+ super::trade_locator_candidate_order(&left, &right),
+ core::cmp::Ordering::Less
+ );
+ }
+
#[cfg(feature = "serde_json")]
#[test]
fn order_event_records_decode_wire_events_and_decode_errors() {
@@ -3290,6 +3454,79 @@ mod tests {
}
#[test]
+ fn reducer_deduplicates_same_event_id_in_each_typed_family() {
+ let mut duplicate_request = request_record();
+ duplicate_request.payload.order_id = order_id("order-duplicate-request");
+ let requested = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record(), duplicate_request],
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_eq!(requested.request_event_id, Some(event_id(1)));
+
+ let mut duplicate_decision = accepted_decision();
+ duplicate_decision.payload.order_id = order_id("order-duplicate-decision");
+ let decided = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record()],
+ decisions: vec![accepted_decision(), duplicate_decision],
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_eq!(decided.decision_event_id, Some(event_id(2)));
+
+ let mut duplicate_proposal = revision_proposal();
+ duplicate_proposal.payload.order_id = order_id("order-duplicate-proposal");
+ let proposed = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record()],
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: vec![revision_proposal(), duplicate_proposal],
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_eq!(proposed.pending_revision_event_id, Some(event_id(3)));
+
+ let mut duplicate_revision_decision = accepted_revision_decision();
+ duplicate_revision_decision.payload.order_id = order_id("order-duplicate-revision");
+ let revision_decided = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record()],
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: vec![revision_proposal()],
+ revision_decisions: vec![accepted_revision_decision(), duplicate_revision_decision],
+ cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
+ },
+ );
+ assert_eq!(revision_decided.agreement_event_id, Some(event_id(4)));
+
+ let mut duplicate_cancellation = cancellation(event_id(1));
+ duplicate_cancellation.payload.order_id = order_id("order-duplicate-cancellation");
+ let cancelled = reduce_order_events(
+ &order_id("order-1"),
+ RadrootsOrderReductionInputs {
+ requests: vec![request_record()],
+ decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
+ revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(),
+ revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(),
+ cancellations: vec![cancellation(event_id(1)), duplicate_cancellation],
+ },
+ );
+ assert_eq!(cancelled.cancellation_event_id, Some(event_id(5)));
+ }
+
+ #[test]
fn canonicalize_order_request_reports_signer_listing_and_item_errors() {
let canonical =
super::canonicalize_order_request_for_signer(request_record().payload, BUYER).unwrap();
@@ -3782,13 +4019,71 @@ mod tests {
RadrootsOrderIssue::CancellationPreviousMismatch {
event_id: id.clone(),
},
- RadrootsOrderIssue::ForkedLifecycle { event_ids },
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: event_ids.clone(),
+ },
+ RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ValidationReceiptOrderIdMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ValidationReceiptTypeMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ValidationReceiptRootMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ValidationReceiptTargetMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ValidationReceiptListingMismatch {
+ event_id: id.clone(),
+ },
+ RadrootsOrderIssue::ConflictingValidationReceipts {
+ event_ids: event_ids.clone(),
+ },
+ RadrootsOrderIssue::DeterministicValidationFailure {
+ event_id: id.clone(),
+ reason: "failed".into(),
+ },
+ RadrootsOrderIssue::StaleListingEvent {
+ expected_event_id: id.clone(),
+ current_event_id: event_id(43),
+ },
];
for (rank, issue) in issues.iter().enumerate() {
assert_eq!(super::order_issue_rank(issue), rank as u8);
}
- assert_eq!(super::projection_issue_event_ids(&issues), vec![id]);
+ assert_eq!(
+ super::projection_issue_event_ids(&issues),
+ vec![id, event_id(43)]
+ );
+
+ let mut projection = super::RadrootsOrderProjection {
+ order_id: order_id("order-1"),
+ status: RadrootsTradeWorkflowState::Invalid,
+ request_event_id: None,
+ decision_event_id: None,
+ cancellation_event_id: None,
+ validation_receipt_event_id: None,
+ lifecycle_terminal: true,
+ economics: None,
+ agreement_event_id: None,
+ pending_revision_event_id: None,
+ pending_inventory_reservations: Vec::new(),
+ committed_inventory_reservations: Vec::new(),
+ listing_addr: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ last_event_id: None,
+ issues: vec![RadrootsOrderIssue::ValidationReceiptRootMismatch {
+ event_id: event_id(44),
+ }],
+ };
+ projection.finish_issue_state();
+ assert_eq!(projection.last_event_id, Some(event_id(44)));
}
#[test]
@@ -3974,7 +4269,23 @@ mod tests {
bin_count: 1,
},
],
- committed_orders: Vec::new(),
+ committed_orders: vec![
+ super::RadrootsListingInventoryOrderReservation {
+ order_id: order_id("order-2"),
+ agreement_event_id: event_id(95),
+ bin_count: 1,
+ },
+ super::RadrootsListingInventoryOrderReservation {
+ order_id: order_id("order-1"),
+ agreement_event_id: event_id(94),
+ bin_count: 1,
+ },
+ super::RadrootsListingInventoryOrderReservation {
+ order_id: order_id("order-1"),
+ agreement_event_id: event_id(93),
+ bin_count: 1,
+ },
+ ],
};
let mut finish_issues = Vec::new();
super::finish_inventory_accounting_bins(
@@ -3988,6 +4299,14 @@ mod tests {
sorting_bin.pending_orders[0].agreement_event_id,
event_id(90)
);
+ assert_eq!(
+ sorting_bin.committed_orders[0].order_id,
+ order_id("order-1")
+ );
+ assert_eq!(
+ sorting_bin.committed_orders[0].agreement_event_id,
+ event_id(93)
+ );
assert_inventory_issue_kind(
&finish_issues,
RadrootsListingInventoryAccountingIssue::OverReserved {
diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs
@@ -526,20 +526,15 @@ pub fn validation_receipt_tags_from_tags(
let event_set_root = required_tag_value(tags, TAG_VALIDATION_RECEIPT_EVENT_SET_ROOT)?;
let reducer_output_root = required_tag_value(tags, TAG_VALIDATION_RECEIPT_REDUCER_OUTPUT_ROOT)?;
let public_values_hash = required_tag_value(tags, TAG_VALIDATION_RECEIPT_PUBLIC_VALUES_HASH)?;
- let proof_system = RadrootsValidationReceiptProofSystem::from_label(&required_tag_value(
- tags,
- TAG_VALIDATION_RECEIPT_PROOF_SYSTEM,
- )?)
- .ok_or(RadrootsValidationReceiptError::InvalidTag(
- TAG_VALIDATION_RECEIPT_PROOF_SYSTEM,
- ))?;
- let receipt_type = RadrootsValidationReceiptType::from_label(&required_tag_value(
- tags,
- TAG_VALIDATION_RECEIPT_RECEIPT_TYPE,
- )?)
- .ok_or(RadrootsValidationReceiptError::InvalidTag(
- TAG_VALIDATION_RECEIPT_RECEIPT_TYPE,
- ))?;
+ let proof_system_label = required_tag_value(tags, TAG_VALIDATION_RECEIPT_PROOF_SYSTEM)?;
+ let proof_system = RadrootsValidationReceiptProofSystem::from_label(&proof_system_label)
+ .ok_or(RadrootsValidationReceiptError::InvalidTag(
+ TAG_VALIDATION_RECEIPT_PROOF_SYSTEM,
+ ))?;
+ let receipt_type_label = required_tag_value(tags, TAG_VALIDATION_RECEIPT_RECEIPT_TYPE)?;
+ let receipt_type = RadrootsValidationReceiptType::from_label(&receipt_type_label).ok_or(
+ RadrootsValidationReceiptError::InvalidTag(TAG_VALIDATION_RECEIPT_RECEIPT_TYPE),
+ )?;
validate_event_id(&listing_event_id, "tags.e.listing")?;
validate_event_id(&root_event_id, "tags.e.root")?;
@@ -750,9 +745,7 @@ fn required_event_marker(
if matches.next().is_some() {
return Err(RadrootsValidationReceiptError::InvalidTag(marker));
}
- let value = tag
- .get(1)
- .ok_or(RadrootsValidationReceiptError::InvalidTag(marker))?;
+ let value = &tag[1];
validate_required_str(value, marker)?;
Ok(value.clone())
}
@@ -869,7 +862,10 @@ mod tests {
validation_receipt_tags, validation_receipt_tags_from_tags,
verify_validation_receipt_event,
};
- use radroots_events::{RadrootsNostrEvent, kinds::KIND_TRADE_VALIDATION_RECEIPT, tags::TAG_D};
+ use radroots_events::{
+ RadrootsNostrEvent, ids::RadrootsPublicKey, kinds::KIND_TRADE_VALIDATION_RECEIPT,
+ tags::TAG_D,
+ };
fn hash32(c: char) -> String {
format!("0x{}", c.to_string().repeat(64))
@@ -1037,6 +1033,24 @@ mod tests {
}
#[test]
+ fn validation_trust_policy_builders_preserve_explicit_settings() {
+ let trusted = RadrootsPublicKey::parse(&event_id('a')).unwrap();
+ let other = RadrootsPublicKey::parse(&event_id('b')).unwrap();
+ let policy = RadrootsTradeValidationTrustPolicy::production()
+ .with_trusted_rhi_pubkeys(vec![trusted.clone()])
+ .with_allow_deterministic_none(true)
+ .with_require_cryptographic_proof(false);
+
+ assert_eq!(policy.trusted_rhi_pubkey_count(), 1);
+ assert!(policy.trusts_rhi_pubkey(&trusted));
+ assert!(!policy.trusts_rhi_pubkey(&other));
+ assert!(policy.allow_deterministic_none);
+ assert!(!policy.require_cryptographic_proof);
+ assert!(RadrootsTradeValidationTrustPolicy::default().require_cryptographic_proof);
+ assert!(RadrootsTradeValidationTrustPolicy::explicit_dev_test().allow_deterministic_none);
+ }
+
+ #[test]
fn validation_receipt_validate_rejects_core_field_errors() {
let mut receipt = sample_validation_receipt();
receipt.version = 2;
@@ -1128,6 +1142,33 @@ mod tests {
);
let mut receipt = sample_validation_receipt();
+ receipt.new_state_root = "bad".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "new_state_root"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.previous_state_root = "bad".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "previous_state_root"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.statement.target_event_id = "bad".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "statement.target_event_id"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
receipt.error_bitmap = "0x00000000000000000000000000000001".to_string();
assert_eq!(
receipt.validate(),
@@ -1353,6 +1394,56 @@ mod tests {
TAG_VALIDATION_RECEIPT_RECEIPT_TYPE
))
);
+
+ for marker in ["root", "target"] {
+ let missing = tags
+ .iter()
+ .filter(|tag| tag.get(4).map(String::as_str) != Some(marker))
+ .cloned()
+ .collect::<Vec<_>>();
+ assert_eq!(
+ validation_receipt_tags_from_tags(&missing),
+ Err(RadrootsValidationReceiptError::MissingTag(marker))
+ );
+ }
+
+ let mut malformed_root = tags.clone();
+ malformed_root[2] = vec![
+ "e".to_string(),
+ String::new(),
+ String::new(),
+ "root".to_string(),
+ ];
+ assert_eq!(
+ validation_receipt_tags_from_tags(&malformed_root),
+ Err(RadrootsValidationReceiptError::MissingTag("root"))
+ );
+
+ let mut missing_root_value = tags.clone();
+ missing_root_value[2] = vec![
+ "e".to_string(),
+ String::new(),
+ String::new(),
+ "root".to_string(),
+ ];
+ missing_root_value[2].insert(1, event_id('1'));
+ missing_root_value[2].remove(1);
+ assert_eq!(
+ validation_receipt_tags_from_tags(&missing_root_value),
+ Err(RadrootsValidationReceiptError::MissingTag("root"))
+ );
+
+ let mut malformed_target = tags.clone();
+ malformed_target[3] = vec![
+ "e".to_string(),
+ String::new(),
+ String::new(),
+ "target".to_string(),
+ ];
+ assert_eq!(
+ validation_receipt_tags_from_tags(&malformed_target),
+ Err(RadrootsValidationReceiptError::MissingTag("target"))
+ );
}
#[test]
@@ -2015,6 +2106,30 @@ mod tests {
}
#[test]
+ fn validation_receipt_builders_reject_invalid_receipts_before_serializing() {
+ let mut receipt = sample_validation_receipt();
+ receipt.version = 2;
+ let content = serde_json::to_string(&receipt).unwrap();
+
+ assert_eq!(
+ validation_receipt_canonical_content(&receipt),
+ Err(RadrootsValidationReceiptError::InvalidField("version"))
+ );
+ assert_eq!(
+ validation_receipt_content_from_str(&content),
+ Err(RadrootsValidationReceiptError::InvalidField("version"))
+ );
+ assert_eq!(
+ validation_receipt_tags("order-1", &receipt),
+ Err(RadrootsValidationReceiptError::InvalidField("version"))
+ );
+ assert!(matches!(
+ validation_receipt_event_build("order-1", &receipt),
+ Err(RadrootsValidationReceiptError::InvalidField("version"))
+ ));
+ }
+
+ #[test]
fn validation_receipt_tag_builder_rejects_empty_order_id() {
assert_eq!(
validation_receipt_tags("", &sample_validation_receipt()),
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -200,9 +200,7 @@ fn apply_validation_receipts(
return;
}
- let Some(receipt) = valid_receipts.first() else {
- return;
- };
+ let receipt = &valid_receipts[0];
match receipt.receipt.result {
RadrootsValidationReceiptResult::Valid => {
@@ -304,6 +302,7 @@ pub fn inventory_reservations_from_commitments(
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
@@ -327,6 +326,7 @@ mod tests {
RadrootsGroupedOrderEventRecords, RadrootsOrderCancellationRecord,
RadrootsOrderDecisionRecord, RadrootsOrderIssue, RadrootsOrderRequestRecord,
RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord,
+ RadrootsTradeLocatorProjectionResolution,
};
use crate::validation_receipt::{
RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof,
@@ -339,8 +339,10 @@ mod tests {
use super::{
RadrootsTradeWorkflowDeterministicFailure, RadrootsTradeWorkflowRecords,
RadrootsTradeWorkflowState, RadrootsTradeWorkflowValidationReceiptRecord,
- reduce_trade_workflow_records,
+ inventory_reservations_from_commitments, reduce_trade_workflow_records,
+ reduce_trade_workflow_records_for_trade_locator,
};
+ use crate::identity::RadrootsTradeLocator;
const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -645,6 +647,25 @@ mod tests {
assert_eq!(projection.validation_receipt_event_id, Some(event_id(9)));
assert!(projection.pending_inventory_reservations.is_empty());
assert_eq!(projection.committed_inventory_reservations.len(), 1);
+
+ let mut no_listing_expectation = workflow_records();
+ no_listing_expectation.expected_listing_event_id = None;
+ no_listing_expectation.current_listing_event_id = None;
+ no_listing_expectation
+ .order_events
+ .decisions
+ .push(accepted_decision());
+ no_listing_expectation
+ .validation_receipts
+ .push(receipt_record(
+ 10,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ ));
+ let projection = reduce_trade_workflow_records(&order_id(), no_listing_expectation);
+ assert_eq!(projection.status, RadrootsTradeWorkflowState::Committed);
}
#[test]
@@ -751,4 +772,231 @@ mod tests {
[RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. }]
));
}
+
+ #[test]
+ fn workflow_locator_resolution_reports_missing_ambiguous_and_matched_records() {
+ let locator = RadrootsTradeLocator::from_order_id(order_id());
+
+ let missing = reduce_trade_workflow_records_for_trade_locator(
+ &locator,
+ RadrootsTradeWorkflowRecords::default(),
+ );
+ assert!(matches!(
+ missing,
+ RadrootsTradeLocatorProjectionResolution::Missing { .. }
+ ));
+
+ let mut ambiguous = workflow_records();
+ let mut second_request = request_record();
+ second_request.event_id = event_id(9);
+ ambiguous.order_events.requests.push(second_request);
+ let ambiguous_resolution =
+ reduce_trade_workflow_records_for_trade_locator(&locator, ambiguous);
+ assert!(matches!(
+ ambiguous_resolution,
+ RadrootsTradeLocatorProjectionResolution::Ambiguous { ref candidates, .. }
+ if candidates.len() == 2
+ ));
+
+ let mut matched = workflow_records();
+ matched.order_events.decisions.push(accepted_decision());
+ matched.validation_receipts.push(receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ ));
+ matched.validation_receipts.push(receipt_record(
+ 10,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(9),
+ event_id(2),
+ event_id(80),
+ ));
+ let locator =
+ RadrootsTradeLocator::from_order_id(order_id()).with_root_event_id(event_id(1));
+ let matched_resolution = reduce_trade_workflow_records_for_trade_locator(&locator, matched);
+ assert!(matches!(
+ matched_resolution,
+ RadrootsTradeLocatorProjectionResolution::Projected { projection, .. }
+ if projection.status == RadrootsTradeWorkflowState::Committed
+ && projection.validation_receipt_event_id == Some(event_id(9))
+ ));
+ }
+
+ #[test]
+ fn workflow_receipt_binding_reports_each_mismatch_variant() {
+ let mut conflicting = workflow_records();
+ conflicting.order_events.decisions.push(accepted_decision());
+ conflicting.validation_receipts.push(receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ ));
+ conflicting.validation_receipts.push(receipt_record(
+ 10,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ ));
+ let projection = reduce_trade_workflow_records(&order_id(), conflicting);
+ assert!(matches!(
+ projection.issues.as_slice(),
+ [RadrootsOrderIssue::ConflictingValidationReceipts { .. }]
+ ));
+
+ let mut no_pending_agreement = workflow_records();
+ no_pending_agreement
+ .validation_receipts
+ .push(receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ ));
+ let projection = reduce_trade_workflow_records(&order_id(), no_pending_agreement);
+ assert!(matches!(
+ projection.issues.as_slice(),
+ [
+ RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. },
+ ..
+ ]
+ ));
+
+ let mut wrong_order = workflow_records();
+ wrong_order.order_events.decisions.push(accepted_decision());
+ let mut receipt = receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ );
+ receipt.order_id = RadrootsOrderId::parse("order-2").unwrap();
+ wrong_order.validation_receipts.push(receipt);
+ let projection = reduce_trade_workflow_records(&order_id(), wrong_order);
+ assert!(projection.issues.iter().any(|issue| {
+ matches!(
+ issue,
+ RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. }
+ )
+ }));
+
+ let mut wrong_order_tag = workflow_records();
+ wrong_order_tag
+ .order_events
+ .decisions
+ .push(accepted_decision());
+ let mut receipt = receipt_record(
+ 10,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ );
+ receipt.tags.order_id = "order-2".to_string();
+ wrong_order_tag.validation_receipts.push(receipt);
+ let projection = reduce_trade_workflow_records(&order_id(), wrong_order_tag);
+ assert!(projection.issues.iter().any(|issue| {
+ matches!(
+ issue,
+ RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. }
+ )
+ }));
+
+ let mut wrong_type = workflow_records();
+ wrong_type.order_events.decisions.push(accepted_decision());
+ let mut receipt = receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ );
+ receipt.receipt.receipt_type = RadrootsValidationReceiptType::ListingValidation;
+ wrong_type.validation_receipts.push(receipt);
+ let projection = reduce_trade_workflow_records(&order_id(), wrong_type);
+ assert!(projection.issues.iter().any(|issue| {
+ matches!(
+ issue,
+ RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. }
+ )
+ }));
+
+ let mut wrong_type_tag = workflow_records();
+ wrong_type_tag
+ .order_events
+ .decisions
+ .push(accepted_decision());
+ let mut receipt = receipt_record(
+ 10,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(80),
+ );
+ receipt.tags.receipt_type = RadrootsValidationReceiptType::ListingValidation;
+ wrong_type_tag.validation_receipts.push(receipt);
+ let projection = reduce_trade_workflow_records(&order_id(), wrong_type_tag);
+ assert!(projection.issues.iter().any(|issue| {
+ matches!(
+ issue,
+ RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. }
+ )
+ }));
+
+ let mut wrong_root = workflow_records();
+ wrong_root.order_events.decisions.push(accepted_decision());
+ wrong_root.validation_receipts.push(receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(3),
+ event_id(2),
+ event_id(80),
+ ));
+ let projection = reduce_trade_workflow_records(&order_id(), wrong_root);
+ assert!(projection.issues.iter().any(|issue| {
+ matches!(
+ issue,
+ RadrootsOrderIssue::ValidationReceiptRootMismatch { .. }
+ )
+ }));
+
+ let mut wrong_listing = workflow_records();
+ wrong_listing
+ .order_events
+ .decisions
+ .push(accepted_decision());
+ wrong_listing.validation_receipts.push(receipt_record(
+ 9,
+ RadrootsValidationReceiptResult::Valid,
+ event_id(1),
+ event_id(2),
+ event_id(81),
+ ));
+ let projection = reduce_trade_workflow_records(&order_id(), wrong_listing);
+ assert!(projection.issues.iter().any(|issue| {
+ matches!(
+ issue,
+ RadrootsOrderIssue::ValidationReceiptListingMismatch { .. }
+ )
+ }));
+
+ let reservations = inventory_reservations_from_commitments(&[
+ RadrootsOrderInventoryCommitment {
+ bin_id: bin_id("bin-2"),
+ bin_count: 1,
+ },
+ RadrootsOrderInventoryCommitment {
+ bin_id: bin_id("bin-1"),
+ bin_count: 1,
+ },
+ ]);
+ assert_eq!(reservations[0].bin_id, bin_id("bin-1"));
+ }
}
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -69,6 +69,7 @@ fn run_release(args: &[String]) -> Result<(), String> {
}
}
+#[cfg_attr(coverage_nightly, coverage(off))]
fn run_contract(args: &[String]) -> Result<(), String> {
match args.first().map(String::as_str) {
Some("validate") => validate_contract(),