lib

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

commit 91d3ebf3075e07d796b897500b85adc321f167ff
parent 55107d80888af223aa614d2fc6bebaa1227d1597
Author: triesap <tyson@radroots.org>
Date:   Sun,  5 Jul 2026 05:47:43 +0000

events-codec: add knowledge verified decode

- add knowledge encode and decode support for wiki and custom contracts
- introduce NIP-01 type-state verification and contract-id dispatch
- expose stable codec error codes and knowledge tag-builder impls
- harden the no-default codec portability lane

Diffstat:
Mcrates/events_codec/Cargo.toml | 2++
Mcrates/events_codec/src/article/decode.rs | 5++++-
Mcrates/events_codec/src/article/encode.rs | 5++++-
Mcrates/events_codec/src/calendar/decode.rs | 5++++-
Mcrates/events_codec/src/calendar/encode.rs | 7++++++-
Mcrates/events_codec/src/comment/encode.rs | 1+
Mcrates/events_codec/src/coop/encode.rs | 1+
Mcrates/events_codec/src/document/encode.rs | 1+
Mcrates/events_codec/src/error.rs | 25+++++++++++++++++++++++++
Mcrates/events_codec/src/event_ref.rs | 1+
Mcrates/events_codec/src/farm/encode.rs | 1+
Mcrates/events_codec/src/farm_crdt/encode.rs | 5+----
Mcrates/events_codec/src/farm_workspace/encode.rs | 5+----
Mcrates/events_codec/src/file_metadata/decode.rs | 5++++-
Mcrates/events_codec/src/file_metadata/encode.rs | 5++++-
Mcrates/events_codec/src/http_auth/decode.rs | 5+----
Mcrates/events_codec/src/http_auth/encode.rs | 5+----
Acrates/events_codec/src/knowledge/decode.rs | 439+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/knowledge/encode.rs | 441+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/knowledge/mod.rs | 21+++++++++++++++++++++
Mcrates/events_codec/src/lib.rs | 11+++++++++++
Mcrates/events_codec/src/list_set/encode.rs | 1+
Mcrates/events_codec/src/listing/encode.rs | 10+++++-----
Mcrates/events_codec/src/order/tags.rs | 2+-
Mcrates/events_codec/src/plot/encode.rs | 1+
Mcrates/events_codec/src/post/decode.rs | 1+
Mcrates/events_codec/src/reaction/encode.rs | 1+
Mcrates/events_codec/src/relay_auth/decode.rs | 5+----
Mcrates/events_codec/src/relay_auth/encode.rs | 5+----
Mcrates/events_codec/src/report/decode.rs | 5++++-
Mcrates/events_codec/src/report/encode.rs | 7++++++-
Mcrates/events_codec/src/repost/decode.rs | 5++++-
Mcrates/events_codec/src/repost/encode.rs | 7++++++-
Mcrates/events_codec/src/resource_area/encode.rs | 1+
Mcrates/events_codec/src/resource_cap/encode.rs | 1+
Mcrates/events_codec/src/social_helpers.rs | 7++++++-
Mcrates/events_codec/src/tag_builders.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/verification.rs | 359+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/tests/knowledge.rs | 459+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
39 files changed, 1946 insertions(+), 41 deletions(-)

diff --git a/crates/events_codec/Cargo.toml b/crates/events_codec/Cargo.toml @@ -17,6 +17,8 @@ std = ["radroots_core/std", "radroots_events/std"] serde = ["dep:serde", "radroots_core/serde", "radroots_events/serde"] serde_json = ["serde", "dep:serde_json"] nostr = ["dep:nostr", "std"] +knowledge = ["serde_json", "radroots_events/knowledge"] +knowledge-nip54 = ["knowledge", "radroots_events/knowledge-nip54"] [dependencies] radroots_core = { workspace = true, default-features = false } diff --git a/crates/events_codec/src/article/decode.rs b/crates/events_codec/src/article/decode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/article/encode.rs b/crates/events_codec/src/article/encode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ article::RadrootsArticle, diff --git a/crates/events_codec/src/calendar/decode.rs b/crates/events_codec/src/calendar/decode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/calendar/encode.rs b/crates/events_codec/src/calendar/encode.rs @@ -1,5 +1,10 @@ #[cfg(not(feature = "std"))] -use alloc::{format, string::ToString, vec, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; use radroots_events::{ calendar::{ diff --git a/crates/events_codec/src/comment/encode.rs b/crates/events_codec/src/comment/encode.rs @@ -2,6 +2,7 @@ use alloc::{ format, string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/coop/encode.rs b/crates/events_codec/src/coop/encode.rs @@ -3,6 +3,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/document/encode.rs b/crates/events_codec/src/document/encode.rs @@ -3,6 +3,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/error.rs b/crates/events_codec/src/error.rs @@ -9,6 +9,18 @@ pub enum EventParseError { InvalidJson(&'static str), } +impl EventParseError { + pub const fn code(&self) -> &'static str { + match self { + Self::MissingTag(_) => "missing_tag", + Self::InvalidTag(_) => "invalid_tag", + Self::InvalidKind { .. } => "invalid_kind", + Self::InvalidNumber(_, _) => "invalid_number", + Self::InvalidJson(_) => "invalid_json", + } + } +} + impl fmt::Display for EventParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -41,6 +53,19 @@ pub enum EventEncodeError { Json, } +pub type RadrootsEncodeError = EventEncodeError; + +impl EventEncodeError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidKind(_) => "invalid_kind", + Self::EmptyRequiredField(_) => "empty_required_field", + Self::InvalidField(_) => "invalid_field", + Self::Json => "json", + } + } +} + impl fmt::Display for EventEncodeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/crates/events_codec/src/event_ref.rs b/crates/events_codec/src/event_ref.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/farm/encode.rs b/crates/events_codec/src/farm/encode.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/farm_crdt/encode.rs b/crates/events_codec/src/farm_crdt/encode.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; #[cfg(feature = "serde_json")] use radroots_events::farm_crdt::KIND_FARM_CRDT_CHANGE; diff --git a/crates/events_codec/src/farm_workspace/encode.rs b/crates/events_codec/src/farm_workspace/encode.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; use radroots_events::{ farm_workspace::{ diff --git a/crates/events_codec/src/file_metadata/decode.rs b/crates/events_codec/src/file_metadata/decode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/file_metadata/encode.rs b/crates/events_codec/src/file_metadata/encode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ file_metadata::RadrootsFileMetadata, diff --git a/crates/events_codec/src/http_auth/decode.rs b/crates/events_codec/src/http_auth/decode.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/http_auth/encode.rs b/crates/events_codec/src/http_auth/encode.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; use radroots_events::{ http_auth::{KIND_HTTP_AUTH, RadrootsHttpAuth}, diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs @@ -0,0 +1,439 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::kinds::{ + KIND_CONTRIBUTION_ATTESTATION, KIND_EVIDENCE_BOUNTY, KIND_KNOWLEDGE_CHANGE_PROPOSAL, + KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, + KIND_KNOWLEDGE_REVIEW, KIND_KNOWLEDGE_SOURCE, KIND_WIKI_ARTICLE, KIND_WIKI_MERGE_REQUEST, + KIND_WIKI_REDIRECT, +}; +use radroots_events::knowledge::{ + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation, + RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim, + RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, + RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiMergeRequest, RadrootsWikiRedirect, + validate_wiki_d_tag, +}; +use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_SUMMARY, TAG_T}; +use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventRef}; +use serde::de::DeserializeOwned; + +use crate::error::EventParseError; +use crate::event_ref::parse_event_ref_tag; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const TAG_TITLE: &str = "title"; +const TAG_SOURCE: &str = "source"; +const TAG_REVIEW_TARGET: &str = "review_target"; +const TAG_FORK: &str = "fork"; +const TAG_DEFERRED_TO: &str = "deferred_to"; +const E_MARKER_SOURCE: &str = "source"; + +fn ensure_kind(kind: u32, expected: u32, name: &'static str) -> Result<(), EventParseError> { + if kind == expected { + Ok(()) + } else { + Err(EventParseError::InvalidKind { + expected: name, + got: kind, + }) + } +} + +fn matching_tags<'a>(tags: &'a [Vec<String>], name: &'static str) -> Vec<&'a Vec<String>> { + tags.iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(name)) + .collect() +} + +fn required_one_value(tags: &[Vec<String>], name: &'static str) -> Result<String, EventParseError> { + let matches = matching_tags(tags, name); + if matches.is_empty() { + return Err(EventParseError::MissingTag(name)); + } + if matches.len() > 1 { + return Err(EventParseError::InvalidTag(name)); + } + let value = matches[0].get(1).ok_or(EventParseError::InvalidTag(name))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(name)); + } + Ok(value.clone()) +} + +fn optional_one_value( + tags: &[Vec<String>], + name: &'static str, +) -> Result<Option<String>, EventParseError> { + let matches = matching_tags(tags, name); + if matches.len() > 1 { + return Err(EventParseError::InvalidTag(name)); + } + Ok(matches + .first() + .and_then(|tag| tag.get(1)) + .filter(|value| !value.trim().is_empty()) + .cloned()) +} + +fn values(tags: &[Vec<String>], name: &'static str) -> Vec<String> { + tags.iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(name)) + .filter_map(|tag| tag.get(1)) + .filter(|value| !value.trim().is_empty()) + .cloned() + .collect() +} + +fn event_refs( + tags: &[Vec<String>], + name: &'static str, +) -> Result<Vec<RadrootsNostrEventRef>, EventParseError> { + matching_tags(tags, name) + .into_iter() + .map(|tag| parse_event_ref_tag(tag, name)) + .collect() +} + +fn first_event_ref( + tags: &[Vec<String>], + name: &'static str, +) -> Result<Option<RadrootsNostrEventRef>, EventParseError> { + let matches = matching_tags(tags, name); + if matches.len() > 1 { + return Err(EventParseError::InvalidTag(name)); + } + matches + .first() + .map(|tag| parse_event_ref_tag(tag, name)) + .transpose() +} + +fn address_from_a_tag( + tags: &[Vec<String>], + name: &'static str, +) -> Result<RadrootsAddressableRef, EventParseError> { + let tag = matching_tags(tags, name) + .into_iter() + .next() + .ok_or(EventParseError::MissingTag(name))?; + let value = tag.get(1).ok_or(EventParseError::InvalidTag(name))?; + let mut parts = value.splitn(3, ':'); + let kind = parts + .next() + .ok_or(EventParseError::InvalidTag(name))? + .parse() + .map_err(|error| EventParseError::InvalidNumber(name, error))?; + let pubkey = parts + .next() + .filter(|value| !value.trim().is_empty()) + .ok_or(EventParseError::InvalidTag(name))?; + let d_tag = parts + .next() + .filter(|value| !value.trim().is_empty()) + .ok_or(EventParseError::InvalidTag(name))?; + Ok(RadrootsAddressableRef { + kind, + pubkey: pubkey.to_string(), + d_tag: d_tag.to_string(), + relays: tag + .get(2..) + .map(|values| values.to_vec()) + .unwrap_or_default(), + }) +} + +fn event_ref_from_a_tag( + tags: &[Vec<String>], + name: &'static str, +) -> Result<RadrootsNostrEventRef, EventParseError> { + let address = address_from_a_tag(tags, name)?; + Ok(RadrootsNostrEventRef { + id: String::new(), + author: address.pubkey, + kind: address.kind, + d_tag: Some(address.d_tag), + relays: if address.relays.is_empty() { + None + } else { + Some(address.relays) + }, + }) +} + +fn json_content<T: DeserializeOwned>(content: &str) -> Result<T, EventParseError> { + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content")) +} + +fn parsed<T>(event: RadrootsNostrEvent, data: T) -> RadrootsParsedEvent<T> { + let parsed_data = RadrootsParsedData::new( + event.id.clone(), + event.author.clone(), + event.created_at, + event.kind, + data, + ); + RadrootsParsedEvent::new(event, parsed_data) +} + +fn require_contract_tag( + tags: &[Vec<String>], + expected: &'static str, +) -> Result<(), EventParseError> { + let actual = required_one_value(tags, TAG_CONTRACT)?; + if actual == expected { + Ok(()) + } else { + Err(EventParseError::InvalidTag(TAG_CONTRACT)) + } +} + +fn require_schema( + schema: &str, + schema_version: u16, + expected: &'static str, +) -> Result<(), EventParseError> { + if schema != expected { + return Err(EventParseError::InvalidJson("schema")); + } + if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION { + return Err(EventParseError::InvalidJson("schema_version")); + } + Ok(()) +} + +fn require_source_marker( + tags: &[Vec<String>], + source_event_id: &str, +) -> Result<(), EventParseError> { + let has_source = tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_E) + && tag.get(1).map(|value| value.as_str()) == Some(source_event_id) + && tag.iter().skip(2).any(|value| value == E_MARKER_SOURCE) + }); + if has_source { + Ok(()) + } else { + Err(EventParseError::InvalidTag(TAG_E)) + } +} + +fn reject_private_coordinate_keys(content: &str) -> Result<(), EventParseError> { + let value: serde_json::Value = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + if contains_private_coordinate_key(&value) { + Err(EventParseError::InvalidJson("content")) + } else { + Ok(()) + } +} + +fn contains_private_coordinate_key(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(object) => object.iter().any(|(key, value)| { + let lower = key.as_str(); + matches!( + lower, + "latitude" + | "longitude" + | "lat" + | "lon" + | "lng" + | "coordinates" + | "exact_coordinates" + ) || contains_private_coordinate_key(value) + }), + serde_json::Value::Array(values) => values.iter().any(contains_private_coordinate_key), + _ => false, + } +} + +pub fn wiki_article_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsWikiArticle>, EventParseError> { + ensure_kind(event.kind, KIND_WIKI_ARTICLE, "wiki article")?; + let d_tag = required_one_value(&event.tags, TAG_D)?; + validate_wiki_d_tag(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?; + let title = required_one_value(&event.tags, TAG_TITLE)?; + let summary = optional_one_value(&event.tags, TAG_SUMMARY)?; + let topics = values(&event.tags, TAG_T); + let references = event_refs(&event.tags, TAG_SOURCE)?; + let forked_from = event_refs(&event.tags, TAG_FORK)?; + let deferred_to = first_event_ref(&event.tags, TAG_DEFERRED_TO)?; + let article = RadrootsWikiArticle { + d_tag, + title, + content_djot: event.content.clone(), + summary, + topics, + references, + forked_from, + deferred_to, + }; + Ok(parsed(event, article)) +} + +pub fn wiki_redirect_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsWikiRedirect>, EventParseError> { + ensure_kind(event.kind, KIND_WIKI_REDIRECT, "wiki redirect")?; + if !event.content.is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + let d_tag = required_one_value(&event.tags, TAG_D)?; + validate_wiki_d_tag(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?; + let target = event_ref_from_a_tag(&event.tags, TAG_A)?; + Ok(parsed(event, RadrootsWikiRedirect { d_tag, target })) +} + +pub fn wiki_merge_request_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsWikiMergeRequest>, EventParseError> { + ensure_kind(event.kind, KIND_WIKI_MERGE_REQUEST, "wiki merge request")?; + let request: RadrootsWikiMergeRequest = json_content(&event.content)?; + address_from_a_tag(&event.tags, TAG_A)?; + required_one_value(&event.tags, "p")?; + require_source_marker(&event.tags, &request.source_version_event_id)?; + Ok(parsed(event, request)) +} + +pub fn knowledge_source_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsKnowledgeSource>, EventParseError> { + ensure_kind(event.kind, KIND_KNOWLEDGE_SOURCE, "knowledge source")?; + require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA)?; + let source: RadrootsKnowledgeSource = json_content(&event.content)?; + require_schema( + &source.schema, + source.schema_version, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, + )?; + let d_tag = required_one_value(&event.tags, TAG_D)?; + if d_tag != source.d_tag { + return Err(EventParseError::InvalidTag(TAG_D)); + } + Ok(parsed(event, source)) +} + +pub fn evidence_bounty_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsEvidenceBounty>, EventParseError> { + ensure_kind(event.kind, KIND_EVIDENCE_BOUNTY, "evidence bounty")?; + require_contract_tag(&event.tags, RADROOTS_EVIDENCE_BOUNTY_SCHEMA)?; + let bounty: RadrootsEvidenceBounty = json_content(&event.content)?; + require_schema( + &bounty.schema, + bounty.schema_version, + RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + )?; + let d_tag = required_one_value(&event.tags, TAG_D)?; + if d_tag != bounty.d_tag { + return Err(EventParseError::InvalidTag(TAG_D)); + } + Ok(parsed(event, bounty)) +} + +pub fn knowledge_claim_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsKnowledgeClaim>, EventParseError> { + ensure_kind(event.kind, KIND_KNOWLEDGE_CLAIM, "knowledge claim")?; + require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA)?; + let claim: RadrootsKnowledgeClaim = json_content(&event.content)?; + require_schema( + &claim.schema, + claim.schema_version, + RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + )?; + Ok(parsed(event, claim)) +} + +pub fn knowledge_relation_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsKnowledgeRelation>, EventParseError> { + ensure_kind(event.kind, KIND_KNOWLEDGE_RELATION, "knowledge relation")?; + require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_RELATION_SCHEMA)?; + let relation: RadrootsKnowledgeRelation = json_content(&event.content)?; + require_schema( + &relation.schema, + relation.schema_version, + RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + )?; + Ok(parsed(event, relation)) +} + +pub fn knowledge_review_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsKnowledgeReview>, EventParseError> { + ensure_kind(event.kind, KIND_KNOWLEDGE_REVIEW, "knowledge review")?; + require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_REVIEW_SCHEMA)?; + required_one_value(&event.tags, TAG_REVIEW_TARGET)?; + let review: RadrootsKnowledgeReview = json_content(&event.content)?; + require_schema( + &review.schema, + review.schema_version, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, + )?; + Ok(parsed(event, review)) +} + +pub fn knowledge_field_report_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsKnowledgeFieldReport>, EventParseError> { + ensure_kind( + event.kind, + KIND_KNOWLEDGE_FIELD_REPORT, + "knowledge field report", + )?; + require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA)?; + reject_private_coordinate_keys(&event.content)?; + let report: RadrootsKnowledgeFieldReport = json_content(&event.content)?; + require_schema( + &report.schema, + report.schema_version, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, + )?; + Ok(parsed(event, report)) +} + +pub fn knowledge_change_proposal_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsKnowledgeChangeProposal>, EventParseError> { + ensure_kind( + event.kind, + KIND_KNOWLEDGE_CHANGE_PROPOSAL, + "knowledge change proposal", + )?; + require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA)?; + let proposal: RadrootsKnowledgeChangeProposal = json_content(&event.content)?; + require_schema( + &proposal.schema, + proposal.schema_version, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, + )?; + Ok(parsed(event, proposal)) +} + +pub fn contribution_attestation_from_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsParsedEvent<RadrootsContributionAttestation>, EventParseError> { + ensure_kind( + event.kind, + KIND_CONTRIBUTION_ATTESTATION, + "contribution attestation", + )?; + require_contract_tag(&event.tags, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA)?; + let attestation: RadrootsContributionAttestation = json_content(&event.content)?; + require_schema( + &attestation.schema, + attestation.schema_version, + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, + )?; + Ok(parsed(event, attestation)) +} diff --git a/crates/events_codec/src/knowledge/encode.rs b/crates/events_codec/src/knowledge/encode.rs @@ -0,0 +1,441 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; + +use radroots_events::RadrootsNostrEventRef; +use radroots_events::kinds::{ + KIND_CONTRIBUTION_ATTESTATION, KIND_EVIDENCE_BOUNTY, KIND_KNOWLEDGE_CHANGE_PROPOSAL, + KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, + KIND_KNOWLEDGE_REVIEW, KIND_KNOWLEDGE_SOURCE, KIND_WIKI_ARTICLE, KIND_WIKI_MERGE_REQUEST, + KIND_WIKI_REDIRECT, +}; +use radroots_events::knowledge::{ + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation, + RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim, + RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, + RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag, +}; +use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_G, TAG_P, TAG_SUMMARY, TAG_T}; +use serde::Serialize; + +use crate::error::EventEncodeError; +use crate::event_ref::build_event_ref_tag; +use crate::wire::{WireEventParts, empty_content}; + +const TAG_TITLE: &str = "title"; +const TAG_SOURCE: &str = "source"; +const TAG_CITATION: &str = "citation"; +const TAG_REVIEW_TARGET: &str = "review_target"; +const TAG_EVIDENCE: &str = "evidence"; +const TAG_FORK: &str = "fork"; +const TAG_DEFERRED_TO: &str = "deferred_to"; +const E_MARKER_SOURCE: &str = "source"; +const E_MARKER_BASE: &str = "base"; + +fn push_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { + if !value.trim().is_empty() { + tags.push(vec![key.to_string(), value.to_string()]); + } +} + +fn push_optional_value(tags: &mut Vec<Vec<String>>, key: &str, value: Option<&str>) { + if let Some(value) = value { + push_value(tags, key, value); + } +} + +fn push_topics(tags: &mut Vec<Vec<String>>, topics: &[String]) { + for topic in topics { + push_value(tags, TAG_T, topic); + } +} + +fn push_event_refs(tags: &mut Vec<Vec<String>>, tag_name: &str, refs: &[RadrootsNostrEventRef]) { + for event_ref in refs { + tags.push(build_event_ref_tag(tag_name, event_ref)); + } +} + +fn address_coordinate(address: &RadrootsAddressableRef) -> String { + format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag) +} + +fn address_tag(tag_name: &str, address: &RadrootsAddressableRef) -> Vec<String> { + let mut tag = Vec::with_capacity(2 + address.relays.len()); + tag.push(tag_name.to_string()); + tag.push(address_coordinate(address)); + tag.extend(address.relays.iter().cloned()); + tag +} + +fn event_ref_address_tag( + tag_name: &str, + event_ref: &RadrootsNostrEventRef, +) -> Result<Vec<String>, EventEncodeError> { + let d_tag = event_ref + .d_tag + .as_ref() + .filter(|value| !value.trim().is_empty()) + .ok_or(EventEncodeError::EmptyRequiredField("target.d_tag"))?; + let mut tag = Vec::with_capacity(2 + event_ref.relays.as_ref().map(|v| v.len()).unwrap_or(0)); + tag.push(tag_name.to_string()); + tag.push(format!("{}:{}:{}", event_ref.kind, event_ref.author, d_tag)); + if let Some(relays) = &event_ref.relays { + tag.extend(relays.iter().cloned()); + } + Ok(tag) +} + +fn review_target_ref(target: &RadrootsKnowledgeReviewTarget) -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: target.event_id.clone(), + author: target.author_pubkey.clone(), + kind: target.kind, + d_tag: target + .address + .as_deref() + .and_then(|address| address.rsplit_once(':').map(|(_, d_tag)| d_tag.to_string())), + relays: if target.relays.is_empty() { + None + } else { + Some(target.relays.clone()) + }, + } +} + +fn json_content<T: Serialize>(value: &T) -> Result<String, EventEncodeError> { + serde_json::to_string(value).map_err(|_| EventEncodeError::Json) +} + +fn require_schema( + actual_schema: &str, + actual_version: u16, + expected_schema: &'static str, +) -> Result<(), EventEncodeError> { + if actual_schema != expected_schema { + return Err(EventEncodeError::InvalidField("schema")); + } + if actual_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION { + return Err(EventEncodeError::InvalidField("schema_version")); + } + Ok(()) +} + +fn custom_tags(contract_id: &'static str) -> Vec<Vec<String>> { + vec![vec![TAG_CONTRACT.to_string(), contract_id.to_string()]] +} + +pub fn wiki_article_build_tags( + article: &RadrootsWikiArticle, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_wiki_d_tag(&article.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?; + if article.title.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("title")); + } + let mut tags = Vec::new(); + tags.push(vec![TAG_D.to_string(), article.d_tag.clone()]); + push_value(&mut tags, TAG_TITLE, &article.title); + push_optional_value(&mut tags, TAG_SUMMARY, article.summary.as_deref()); + push_topics(&mut tags, &article.topics); + push_event_refs(&mut tags, TAG_SOURCE, &article.references); + push_event_refs(&mut tags, TAG_FORK, &article.forked_from); + if let Some(deferred_to) = &article.deferred_to { + tags.push(build_event_ref_tag(TAG_DEFERRED_TO, deferred_to)); + } + Ok(tags) +} + +pub fn wiki_article_to_wire_parts( + article: &RadrootsWikiArticle, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_WIKI_ARTICLE, + content: article.content_djot.clone(), + tags: wiki_article_build_tags(article)?, + }) +} + +pub fn wiki_redirect_build_tags( + redirect: &RadrootsWikiRedirect, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_wiki_d_tag(&redirect.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?; + let mut tags = Vec::new(); + tags.push(vec![TAG_D.to_string(), redirect.d_tag.clone()]); + tags.push(event_ref_address_tag(TAG_A, &redirect.target)?); + Ok(tags) +} + +pub fn wiki_redirect_to_wire_parts( + redirect: &RadrootsWikiRedirect, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_WIKI_REDIRECT, + content: empty_content(), + tags: wiki_redirect_build_tags(redirect)?, + }) +} + +pub fn wiki_merge_request_build_tags( + request: &RadrootsWikiMergeRequest, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + if request.destination_pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("destination_pubkey")); + } + if request.source_version_event_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField( + "source_version_event_id", + )); + } + let mut tags = Vec::new(); + tags.push(address_tag(TAG_A, &request.target_article)); + tags.push(vec![TAG_P.to_string(), request.destination_pubkey.clone()]); + if let Some(base) = request + .base_version_event_id + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + tags.push(vec![ + TAG_E.to_string(), + base.clone(), + String::new(), + E_MARKER_BASE.to_string(), + ]); + } + tags.push(vec![ + TAG_E.to_string(), + request.source_version_event_id.clone(), + String::new(), + E_MARKER_SOURCE.to_string(), + ]); + Ok(tags) +} + +pub fn wiki_merge_request_to_wire_parts( + request: &RadrootsWikiMergeRequest, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_WIKI_MERGE_REQUEST, + content: json_content(request)?, + tags: wiki_merge_request_build_tags(request)?, + }) +} + +pub fn knowledge_source_build_tags( + source: &RadrootsKnowledgeSource, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &source.schema, + source.schema_version, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, + )?; + if source.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("d_tag")); + } + let mut tags = custom_tags(RADROOTS_KNOWLEDGE_SOURCE_SCHEMA); + tags.push(vec![TAG_D.to_string(), source.d_tag.clone()]); + push_topics(&mut tags, &source.topics); + push_event_refs(&mut tags, TAG_SOURCE, &source.artifact_refs); + Ok(tags) +} + +pub fn knowledge_source_to_wire_parts( + source: &RadrootsKnowledgeSource, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_KNOWLEDGE_SOURCE, + content: json_content(source)?, + tags: knowledge_source_build_tags(source)?, + }) +} + +pub fn evidence_bounty_build_tags( + bounty: &RadrootsEvidenceBounty, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &bounty.schema, + bounty.schema_version, + RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + )?; + if bounty.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("d_tag")); + } + let mut tags = custom_tags(RADROOTS_EVIDENCE_BOUNTY_SCHEMA); + tags.push(vec![TAG_D.to_string(), bounty.d_tag.clone()]); + push_topics(&mut tags, &bounty.topics); + push_event_refs(&mut tags, TAG_EVIDENCE, &bounty.target_refs); + Ok(tags) +} + +pub fn evidence_bounty_to_wire_parts( + bounty: &RadrootsEvidenceBounty, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_EVIDENCE_BOUNTY, + content: json_content(bounty)?, + tags: evidence_bounty_build_tags(bounty)?, + }) +} + +pub fn knowledge_claim_build_tags( + claim: &RadrootsKnowledgeClaim, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &claim.schema, + claim.schema_version, + RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + )?; + let mut tags = custom_tags(RADROOTS_KNOWLEDGE_CLAIM_SCHEMA); + push_topics(&mut tags, &claim.topics); + for citation in &claim.citation_spans { + tags.push(build_event_ref_tag(TAG_SOURCE, &citation.source_ref)); + if let Some(quote_hash) = citation.quote_hash.as_deref() { + push_value(&mut tags, TAG_CITATION, quote_hash); + } + } + Ok(tags) +} + +pub fn knowledge_claim_to_wire_parts( + claim: &RadrootsKnowledgeClaim, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_KNOWLEDGE_CLAIM, + content: json_content(claim)?, + tags: knowledge_claim_build_tags(claim)?, + }) +} + +pub fn knowledge_relation_build_tags( + relation: &RadrootsKnowledgeRelation, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &relation.schema, + relation.schema_version, + RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + )?; + let mut tags = custom_tags(RADROOTS_KNOWLEDGE_RELATION_SCHEMA); + push_event_refs(&mut tags, TAG_SOURCE, &relation.support_refs); + Ok(tags) +} + +pub fn knowledge_relation_to_wire_parts( + relation: &RadrootsKnowledgeRelation, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_KNOWLEDGE_RELATION, + content: json_content(relation)?, + tags: knowledge_relation_build_tags(relation)?, + }) +} + +pub fn knowledge_review_build_tags( + review: &RadrootsKnowledgeReview, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &review.schema, + review.schema_version, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, + )?; + let mut tags = custom_tags(RADROOTS_KNOWLEDGE_REVIEW_SCHEMA); + tags.push(build_event_ref_tag( + TAG_REVIEW_TARGET, + &review_target_ref(&review.target), + )); + push_event_refs(&mut tags, TAG_EVIDENCE, &review.evidence_refs); + Ok(tags) +} + +pub fn knowledge_review_to_wire_parts( + review: &RadrootsKnowledgeReview, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_KNOWLEDGE_REVIEW, + content: json_content(review)?, + tags: knowledge_review_build_tags(review)?, + }) +} + +pub fn knowledge_field_report_build_tags( + report: &RadrootsKnowledgeFieldReport, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &report.schema, + report.schema_version, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, + )?; + let mut tags = custom_tags(RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA); + push_topics(&mut tags, &report.context.topics); + if let Some(location) = &report.context.public_location + && let Some(geohash) = location.geohash.as_deref() + { + push_value(&mut tags, TAG_G, geohash); + } + push_event_refs(&mut tags, TAG_EVIDENCE, &report.artifact_refs); + push_event_refs(&mut tags, TAG_EVIDENCE, &report.related_refs); + Ok(tags) +} + +pub fn knowledge_field_report_to_wire_parts( + report: &RadrootsKnowledgeFieldReport, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_KNOWLEDGE_FIELD_REPORT, + content: json_content(report)?, + tags: knowledge_field_report_build_tags(report)?, + }) +} + +pub fn knowledge_change_proposal_build_tags( + proposal: &RadrootsKnowledgeChangeProposal, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &proposal.schema, + proposal.schema_version, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, + )?; + let mut tags = custom_tags(RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA); + push_event_refs(&mut tags, TAG_EVIDENCE, &proposal.evidence_refs); + Ok(tags) +} + +pub fn knowledge_change_proposal_to_wire_parts( + proposal: &RadrootsKnowledgeChangeProposal, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_KNOWLEDGE_CHANGE_PROPOSAL, + content: json_content(proposal)?, + tags: knowledge_change_proposal_build_tags(proposal)?, + }) +} + +pub fn contribution_attestation_build_tags( + attestation: &RadrootsContributionAttestation, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + require_schema( + &attestation.schema, + attestation.schema_version, + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, + )?; + let mut tags = custom_tags(RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA); + push_event_refs(&mut tags, TAG_EVIDENCE, &attestation.evidence_refs); + Ok(tags) +} + +pub fn contribution_attestation_to_wire_parts( + attestation: &RadrootsContributionAttestation, +) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind: KIND_CONTRIBUTION_ATTESTATION, + content: json_content(attestation)?, + tags: contribution_attestation_build_tags(attestation)?, + }) +} diff --git a/crates/events_codec/src/knowledge/mod.rs b/crates/events_codec/src/knowledge/mod.rs @@ -0,0 +1,21 @@ +pub mod decode; +pub mod encode; + +pub use decode::{ + contribution_attestation_from_event, evidence_bounty_from_event, + knowledge_change_proposal_from_event, knowledge_claim_from_event, + knowledge_field_report_from_event, knowledge_relation_from_event, knowledge_review_from_event, + knowledge_source_from_event, wiki_article_from_event, wiki_merge_request_from_event, + wiki_redirect_from_event, +}; +pub use encode::{ + contribution_attestation_build_tags, contribution_attestation_to_wire_parts, + evidence_bounty_build_tags, evidence_bounty_to_wire_parts, + knowledge_change_proposal_build_tags, knowledge_change_proposal_to_wire_parts, + knowledge_claim_build_tags, knowledge_claim_to_wire_parts, knowledge_field_report_build_tags, + knowledge_field_report_to_wire_parts, knowledge_relation_build_tags, + knowledge_relation_to_wire_parts, knowledge_review_build_tags, knowledge_review_to_wire_parts, + knowledge_source_build_tags, knowledge_source_to_wire_parts, wiki_article_build_tags, + wiki_article_to_wire_parts, wiki_merge_request_build_tags, wiki_merge_request_to_wire_parts, + wiki_redirect_build_tags, wiki_redirect_to_wire_parts, +}; diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -8,12 +8,16 @@ pub mod error; pub mod event_ref; mod field_helpers; pub mod job; +#[cfg(feature = "knowledge")] +pub mod knowledge; pub mod parsed; pub mod profile; pub mod report; pub mod repost; mod social_helpers; pub mod tag_builders; +#[cfg(feature = "knowledge")] +pub mod verification; pub mod wire; pub mod app_data; @@ -54,3 +58,10 @@ mod test_fixtures; pub mod relay_document; pub use tag_builders::RadrootsEventTagBuilder; +#[cfg(feature = "knowledge")] +pub use verification::{ + RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent, + RadrootsIdVerifiedEvent, RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, + decode_validated_event, validate_event_contract, verify_and_decode_radroots_event, + verify_event_id, verify_event_signature, +}; diff --git a/crates/events_codec/src/list_set/encode.rs b/crates/events_codec/src/list_set/encode.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/listing/encode.rs b/crates/events_codec/src/listing/encode.rs @@ -1,9 +1,9 @@ +#[cfg(all(not(feature = "std"), test, feature = "serde_json"))] +use alloc::vec; +#[cfg(all(not(feature = "std"), feature = "serde_json"))] +use alloc::{format, string::ToString}; #[cfg(not(feature = "std"))] -use alloc::{ - format, - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; #[cfg(feature = "serde_json")] use radroots_events::kinds::{KIND_LISTING, is_listing_kind}; diff --git a/crates/events_codec/src/order/tags.rs b/crates/events_codec/src/order/tags.rs @@ -1,5 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{borrow::ToOwned, string::String, vec::Vec}; +use alloc::{borrow::ToOwned, string::String, vec, vec::Vec}; use radroots_events::{ RadrootsNostrEventPtr, diff --git a/crates/events_codec/src/plot/encode.rs b/crates/events_codec/src/plot/encode.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/post/decode.rs b/crates/events_codec/src/post/decode.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/reaction/encode.rs b/crates/events_codec/src/reaction/encode.rs @@ -2,6 +2,7 @@ use alloc::{ format, string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/relay_auth/decode.rs b/crates/events_codec/src/relay_auth/decode.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/relay_auth/encode.rs b/crates/events_codec/src/relay_auth/encode.rs @@ -1,8 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; use radroots_events::{ relay_auth::{KIND_RELAY_AUTH, RadrootsRelayAuth}, diff --git a/crates/events_codec/src/report/decode.rs b/crates/events_codec/src/report/decode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/report/encode.rs b/crates/events_codec/src/report/encode.rs @@ -1,5 +1,10 @@ #[cfg(not(feature = "std"))] -use alloc::{format, string::ToString, vec, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; use radroots_events::{ kinds::KIND_REPORT, diff --git a/crates/events_codec/src/repost/decode.rs b/crates/events_codec/src/repost/decode.rs @@ -1,5 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use radroots_events::{ RadrootsNostrEvent, diff --git a/crates/events_codec/src/repost/encode.rs b/crates/events_codec/src/repost/encode.rs @@ -1,5 +1,10 @@ #[cfg(not(feature = "std"))] -use alloc::{format, string::ToString, vec, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; use radroots_events::{ kinds::{KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST}, diff --git a/crates/events_codec/src/resource_area/encode.rs b/crates/events_codec/src/resource_area/encode.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/resource_cap/encode.rs b/crates/events_codec/src/resource_cap/encode.rs @@ -1,6 +1,7 @@ #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, + vec, vec::Vec, }; diff --git a/crates/events_codec/src/social_helpers.rs b/crates/events_codec/src/social_helpers.rs @@ -1,5 +1,10 @@ #[cfg(not(feature = "std"))] -use alloc::{format, string::String, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; use radroots_events::social::{ RadrootsCalendarParticipant, RadrootsSocialFarmAnchor, RadrootsSocialLocation, diff --git a/crates/events_codec/src/tag_builders.rs b/crates/events_codec/src/tag_builders.rs @@ -5,6 +5,13 @@ use alloc::{string::String, vec::Vec}; use core::convert::Infallible; +#[cfg(feature = "knowledge")] +use radroots_events::knowledge::{ + RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, + RadrootsKnowledgeReview, RadrootsKnowledgeSource, RadrootsWikiArticle, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, +}; use radroots_events::{ app_data::RadrootsAppData, comment::RadrootsComment, coop::RadrootsCoop, document::RadrootsDocument, farm::RadrootsFarm, follow::RadrootsFollow, @@ -29,6 +36,14 @@ use crate::job::encode::JobEncodeError; use crate::job::feedback::encode::job_feedback_build_tags; use crate::job::request::encode::job_request_build_tags; use crate::job::result::encode::job_result_build_tags; +#[cfg(feature = "knowledge")] +use crate::knowledge::encode::{ + contribution_attestation_build_tags, evidence_bounty_build_tags, + knowledge_change_proposal_build_tags, knowledge_claim_build_tags, + knowledge_field_report_build_tags, knowledge_relation_build_tags, knowledge_review_build_tags, + knowledge_source_build_tags, wiki_article_build_tags, wiki_merge_request_build_tags, + wiki_redirect_build_tags, +}; use crate::list::encode::list_build_tags; use crate::list_set::encode::list_set_build_tags; use crate::listing::tags::listing_tags; @@ -232,3 +247,102 @@ impl RadrootsEventTagBuilder for RadrootsPost { post_build_tags(self) } } + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsWikiArticle { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + wiki_article_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsWikiRedirect { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + wiki_redirect_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsWikiMergeRequest { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + wiki_merge_request_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsKnowledgeSource { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + knowledge_source_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsKnowledgeClaim { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + knowledge_claim_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsKnowledgeRelation { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + knowledge_relation_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsKnowledgeReview { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + knowledge_review_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsKnowledgeFieldReport { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + knowledge_field_report_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsEvidenceBounty { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + evidence_bounty_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsKnowledgeChangeProposal { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + knowledge_change_proposal_build_tags(self) + } +} + +#[cfg(feature = "knowledge")] +impl RadrootsEventTagBuilder for RadrootsContributionAttestation { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + contribution_attestation_build_tags(self) + } +} diff --git a/crates/events_codec/src/verification.rs b/crates/events_codec/src/verification.rs @@ -0,0 +1,359 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use core::fmt; +#[cfg(feature = "nostr")] +use core::str::FromStr; + +use radroots_events::RadrootsNostrEvent; +use radroots_events::contract::{ + RadrootsContractValidationError, RadrootsEventContract, + validate_event_contract as validate_radroots_event_contract, +}; +use radroots_events::draft::compute_nip01_event_id; +use radroots_events::ids::RadrootsEventId; +use radroots_events::knowledge::{ + RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, + RadrootsKnowledgeReview, RadrootsKnowledgeSource, RadrootsWikiArticle, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, +}; + +use crate::error::EventParseError; +use crate::knowledge::decode::{ + contribution_attestation_from_event, evidence_bounty_from_event, + knowledge_change_proposal_from_event, knowledge_claim_from_event, + knowledge_field_report_from_event, knowledge_relation_from_event, knowledge_review_from_event, + knowledge_source_from_event, wiki_article_from_event, wiki_merge_request_from_event, + wiki_redirect_from_event, +}; +use crate::parsed::RadrootsParsedEvent; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsIdVerifiedEvent { + event: RadrootsNostrEvent, +} + +impl RadrootsIdVerifiedEvent { + pub fn event(&self) -> &RadrootsNostrEvent { + &self.event + } + + pub fn into_event(self) -> RadrootsNostrEvent { + self.event + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsSignatureVerifiedEvent { + event: RadrootsNostrEvent, +} + +impl RadrootsSignatureVerifiedEvent { + pub fn event(&self) -> &RadrootsNostrEvent { + &self.event + } + + pub fn into_event(self) -> RadrootsNostrEvent { + self.event + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsContractValidatedEvent { + event: RadrootsNostrEvent, + contract: &'static RadrootsEventContract, +} + +impl RadrootsContractValidatedEvent { + pub fn event(&self) -> &RadrootsNostrEvent { + &self.event + } + + pub fn contract(&self) -> &'static RadrootsEventContract { + self.contract + } + + pub fn contract_id(&self) -> &'static str { + self.contract.id + } + + pub fn into_event(self) -> RadrootsNostrEvent { + self.event + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsNip01VerificationError { + MalformedEnvelope, + IdMismatch { expected: String, actual: String }, + SignatureInvalid, + SignatureVerificationUnavailable, +} + +impl RadrootsNip01VerificationError { + pub const fn code(&self) -> &'static str { + match self { + Self::MalformedEnvelope => "malformed_envelope", + Self::IdMismatch { .. } => "id_mismatch", + Self::SignatureInvalid => "signature_invalid", + Self::SignatureVerificationUnavailable => "signature_verification_unavailable", + } + } +} + +impl fmt::Display for RadrootsNip01VerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MalformedEnvelope => formatter.write_str("malformed NIP-01 event envelope"), + Self::IdMismatch { expected, actual } => { + write!( + formatter, + "NIP-01 event id mismatch: expected {expected}, got {actual}" + ) + } + Self::SignatureInvalid => formatter.write_str("invalid NIP-01 event signature"), + Self::SignatureVerificationUnavailable => { + formatter.write_str("NIP-01 signature verification requires the nostr feature") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsNip01VerificationError {} + +#[derive(Debug)] +pub enum RadrootsDecodeError { + Nip01Verification(RadrootsNip01VerificationError), + ContractValidation(RadrootsContractValidationError), + EventParse(EventParseError), + UnsupportedContract { contract_id: String }, +} + +impl RadrootsDecodeError { + pub const fn code(&self) -> &'static str { + match self { + Self::Nip01Verification(_) => "nip01_verification", + Self::ContractValidation(_) => "contract_validation", + Self::EventParse(_) => "event_parse", + Self::UnsupportedContract { .. } => "unsupported_contract", + } + } +} + +impl fmt::Display for RadrootsDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01Verification(error) => write!(formatter, "{error}"), + Self::ContractValidation(error) => { + write!( + formatter, + "contract validation failed with code {}", + error.code() + ) + } + Self::EventParse(error) => write!(formatter, "{error}"), + Self::UnsupportedContract { contract_id } => { + write!(formatter, "unsupported event contract `{contract_id}`") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsDecodeError {} + +impl From<EventParseError> for RadrootsDecodeError { + fn from(value: EventParseError) -> Self { + Self::EventParse(value) + } +} + +impl From<RadrootsNip01VerificationError> for RadrootsDecodeError { + fn from(value: RadrootsNip01VerificationError) -> Self { + Self::Nip01Verification(value) + } +} + +impl From<RadrootsContractValidationError> for RadrootsDecodeError { + fn from(value: RadrootsContractValidationError) -> Self { + Self::ContractValidation(value) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub enum RadrootsDecodedEvent { + WikiArticle(RadrootsParsedEvent<RadrootsWikiArticle>), + WikiRedirect(RadrootsParsedEvent<RadrootsWikiRedirect>), + WikiMergeRequest(RadrootsParsedEvent<RadrootsWikiMergeRequest>), + KnowledgeSource(RadrootsParsedEvent<RadrootsKnowledgeSource>), + KnowledgeClaim(RadrootsParsedEvent<RadrootsKnowledgeClaim>), + KnowledgeRelation(RadrootsParsedEvent<RadrootsKnowledgeRelation>), + KnowledgeReview(RadrootsParsedEvent<RadrootsKnowledgeReview>), + KnowledgeFieldReport(RadrootsParsedEvent<RadrootsKnowledgeFieldReport>), + EvidenceBounty(RadrootsParsedEvent<RadrootsEvidenceBounty>), + KnowledgeChangeProposal(RadrootsParsedEvent<RadrootsKnowledgeChangeProposal>), + ContributionAttestation(RadrootsParsedEvent<RadrootsContributionAttestation>), +} + +impl RadrootsDecodedEvent { + pub fn event(&self) -> &RadrootsNostrEvent { + match self { + Self::WikiArticle(parsed) => &parsed.event, + Self::WikiRedirect(parsed) => &parsed.event, + Self::WikiMergeRequest(parsed) => &parsed.event, + Self::KnowledgeSource(parsed) => &parsed.event, + Self::KnowledgeClaim(parsed) => &parsed.event, + Self::KnowledgeRelation(parsed) => &parsed.event, + Self::KnowledgeReview(parsed) => &parsed.event, + Self::KnowledgeFieldReport(parsed) => &parsed.event, + Self::EvidenceBounty(parsed) => &parsed.event, + Self::KnowledgeChangeProposal(parsed) => &parsed.event, + Self::ContributionAttestation(parsed) => &parsed.event, + } + } +} + +pub fn verify_event_id( + event: RadrootsNostrEvent, +) -> Result<RadrootsIdVerifiedEvent, RadrootsNip01VerificationError> { + RadrootsEventId::parse(event.id.as_str()) + .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; + let expected = compute_nip01_event_id( + event.author.as_str(), + event.created_at, + event.kind, + &event.tags, + event.content.as_str(), + ) + .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)? + .into_string(); + if event.id != expected { + return Err(RadrootsNip01VerificationError::IdMismatch { + expected, + actual: event.id, + }); + } + Ok(RadrootsIdVerifiedEvent { event }) +} + +#[cfg(feature = "nostr")] +pub fn verify_event_signature( + event: RadrootsIdVerifiedEvent, +) -> Result<RadrootsSignatureVerifiedEvent, RadrootsNip01VerificationError> { + verify_event_id(event.event.clone())?; + let raw_event = raw_event_from_radroots(&event.event)?; + if raw_event.verify_signature() { + Ok(RadrootsSignatureVerifiedEvent { event: event.event }) + } else { + Err(RadrootsNip01VerificationError::SignatureInvalid) + } +} + +#[cfg(not(feature = "nostr"))] +pub fn verify_event_signature( + _event: RadrootsIdVerifiedEvent, +) -> Result<RadrootsSignatureVerifiedEvent, RadrootsNip01VerificationError> { + Err(RadrootsNip01VerificationError::SignatureVerificationUnavailable) +} + +pub fn validate_event_contract( + event: RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsContractValidatedEvent, RadrootsContractValidationError> { + let contract = validate_radroots_event_contract(&event.event)?; + Ok(RadrootsContractValidatedEvent { + event: event.event, + contract, + }) +} + +pub fn decode_validated_event( + event: RadrootsContractValidatedEvent, +) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> { + match event.contract.id { + "radroots.wiki.article.v1" => Ok(RadrootsDecodedEvent::WikiArticle( + wiki_article_from_event(event.event)?, + )), + "radroots.wiki.redirect.v1" => Ok(RadrootsDecodedEvent::WikiRedirect( + wiki_redirect_from_event(event.event)?, + )), + "radroots.wiki.merge_request.v1" => Ok(RadrootsDecodedEvent::WikiMergeRequest( + wiki_merge_request_from_event(event.event)?, + )), + "radroots.knowledge.source.v1" => Ok(RadrootsDecodedEvent::KnowledgeSource( + knowledge_source_from_event(event.event)?, + )), + "radroots.knowledge.claim.v1" => Ok(RadrootsDecodedEvent::KnowledgeClaim( + knowledge_claim_from_event(event.event)?, + )), + "radroots.knowledge.relation.v1" => Ok(RadrootsDecodedEvent::KnowledgeRelation( + knowledge_relation_from_event(event.event)?, + )), + "radroots.knowledge.review.v1" => Ok(RadrootsDecodedEvent::KnowledgeReview( + knowledge_review_from_event(event.event)?, + )), + "radroots.knowledge.field_report.v1" => Ok(RadrootsDecodedEvent::KnowledgeFieldReport( + knowledge_field_report_from_event(event.event)?, + )), + "radroots.knowledge.evidence_bounty.v1" => Ok(RadrootsDecodedEvent::EvidenceBounty( + evidence_bounty_from_event(event.event)?, + )), + "radroots.knowledge.change_proposal.v1" => { + Ok(RadrootsDecodedEvent::KnowledgeChangeProposal( + knowledge_change_proposal_from_event(event.event)?, + )) + } + "radroots.knowledge.contribution_attestation.v1" => { + Ok(RadrootsDecodedEvent::ContributionAttestation( + contribution_attestation_from_event(event.event)?, + )) + } + contract_id => Err(RadrootsDecodeError::UnsupportedContract { + contract_id: contract_id.to_string(), + }), + } +} + +pub fn verify_and_decode_radroots_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> { + let id_verified = verify_event_id(event)?; + let signature_verified = verify_event_signature(id_verified)?; + let contract_validated = validate_event_contract(signature_verified)?; + decode_validated_event(contract_validated) +} + +#[cfg(feature = "nostr")] +fn raw_event_from_radroots( + event: &RadrootsNostrEvent, +) -> Result<nostr::Event, RadrootsNip01VerificationError> { + let id = nostr::EventId::from_hex(event.id.as_str()) + .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; + let public_key = nostr::PublicKey::from_hex(event.author.as_str()) + .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; + let kind_u16 = + u16::try_from(event.kind).map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; + let mut tags = Vec::with_capacity(event.tags.len()); + for tag in event.tags.iter().cloned() { + tags.push( + nostr::Tag::parse(tag) + .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?, + ); + } + let sig = nostr::secp256k1::schnorr::Signature::from_str(event.sig.as_str()) + .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; + Ok(nostr::Event::new( + id, + public_key, + nostr::Timestamp::from_secs(u64::from(event.created_at)), + nostr::Kind::Custom(kind_u16), + tags, + event.content.clone(), + sig, + )) +} diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs @@ -0,0 +1,459 @@ +#![cfg(all(feature = "knowledge", feature = "nostr"))] + +use radroots_events::RadrootsNostrEvent; +use radroots_events::contract::validate_event_contract_shape; +use radroots_events::kinds::{ + KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_REVIEW, KIND_KNOWLEDGE_SOURCE, KIND_WIKI_ARTICLE, +}; +use radroots_events::knowledge::{ + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation, + RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeCitationSpan, + RadrootsKnowledgeClaim, RadrootsKnowledgeFieldContext, RadrootsKnowledgeFieldReport, + RadrootsKnowledgeLocation, RadrootsKnowledgeLocationPrecision, RadrootsKnowledgeNodeRef, + RadrootsKnowledgeObservation, RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation, + RadrootsKnowledgeReview, RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore, + RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, +}; +use radroots_events_codec::knowledge::{ + contribution_attestation_from_event, contribution_attestation_to_wire_parts, + evidence_bounty_from_event, evidence_bounty_to_wire_parts, + knowledge_change_proposal_from_event, knowledge_change_proposal_to_wire_parts, + knowledge_claim_from_event, knowledge_claim_to_wire_parts, knowledge_field_report_from_event, + knowledge_field_report_to_wire_parts, knowledge_relation_from_event, + knowledge_relation_to_wire_parts, knowledge_review_from_event, knowledge_review_to_wire_parts, + knowledge_source_from_event, 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_from_event, wiki_redirect_to_wire_parts, +}; +use radroots_events_codec::verification::{ + RadrootsDecodeError, RadrootsDecodedEvent, RadrootsNip01VerificationError, + verify_and_decode_radroots_event, +}; +use radroots_events_codec::wire::WireEventParts; + +fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() +} + +fn event_ref(character: char, kind: u32) -> radroots_events::RadrootsNostrEventRef { + radroots_events::RadrootsNostrEventRef { + id: hex_64(character), + author: hex_64('a'), + kind, + d_tag: None, + relays: Some(vec!["wss://relay.radroots.example".to_string()]), + } +} + +fn article_ref() -> radroots_events::RadrootsNostrEventRef { + radroots_events::RadrootsNostrEventRef { + id: hex_64('b'), + author: hex_64('a'), + kind: KIND_WIKI_ARTICLE, + d_tag: Some("soil-health".to_string()), + relays: Some(vec!["wss://relay.radroots.example".to_string()]), + } +} + +fn address_ref() -> RadrootsAddressableRef { + RadrootsAddressableRef { + kind: KIND_WIKI_ARTICLE, + pubkey: hex_64('a'), + d_tag: "soil-health".to_string(), + relays: vec!["wss://relay.radroots.example".to_string()], + } +} + +fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: hex_64('0'), + author: hex_64('a'), + created_at: 1_800_000_000, + kind: parts.kind, + tags: parts.tags, + content: parts.content, + sig: "1".repeat(128), + } +} + +fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent { + let tags = parts + .tags + .into_iter() + .map(nostr::Tag::parse) + .collect::<Result<Vec<_>, _>>() + .expect("tags"); + let keys = + nostr::Keys::parse("0101010101010101010101010101010101010101010101010101010101010101") + .expect("keys"); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(parts.kind as u16), parts.content) + .tags(tags) + .custom_created_at(nostr::Timestamp::from_secs(1_800_000_000)) + .sign_with_keys(&keys) + .expect("signed event"); + RadrootsNostrEvent { + id: event.id.to_hex(), + author: event.pubkey.to_hex(), + created_at: event.created_at.as_secs() as u32, + kind: u32::from(event.kind.as_u16()), + tags: event + .tags + .as_slice() + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(), + content: event.content, + sig: event.sig.to_string(), + } +} + +fn wiki_article() -> RadrootsWikiArticle { + RadrootsWikiArticle { + d_tag: "soil-health".to_string(), + title: "Soil health".to_string(), + content_djot: "# Soil health".to_string(), + summary: Some("Living soil basics".to_string()), + topics: vec!["soil".to_string(), "health".to_string()], + references: vec![event_ref('2', KIND_KNOWLEDGE_SOURCE)], + forked_from: Vec::new(), + deferred_to: None, + } +} + +fn source() -> RadrootsKnowledgeSource { + RadrootsKnowledgeSource { + schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-source".to_string(), + title: "Soil Source".to_string(), + source_type: "book".to_string(), + authors: vec!["A. Example".to_string()], + publisher: Some("Radroots Synthetic Press".to_string()), + publication_year: Some(2026), + edition: None, + canonical_url: None, + artifact_refs: vec![event_ref('3', 1063)], + author_asserted_rights: None, + topics: vec!["soil".to_string()], + summary: Some("Synthetic source".to_string()), + } +} + +fn claim() -> RadrootsKnowledgeClaim { + RadrootsKnowledgeClaim { + schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + claim_type: "practice_effect".to_string(), + text: "Cover crops improve soil structure.".to_string(), + citation_spans: vec![RadrootsKnowledgeCitationSpan { + source_ref: event_ref('4', KIND_KNOWLEDGE_SOURCE), + artifact_ref: None, + page_start: Some(12), + page_end: Some(13), + section_path: vec!["chapter-1".to_string()], + quote_hash: Some(hex_64('5')), + chunk_id: Some("chunk-1".to_string()), + }], + topics: vec!["cover-crops".to_string()], + applies_to: vec!["local-food".to_string()], + author_asserted_confidence: Some("medium".to_string()), + supersedes: Vec::new(), + } +} + +fn node_ref(label: &str) -> RadrootsKnowledgeNodeRef { + RadrootsKnowledgeNodeRef { + node_type: "event".to_string(), + event_ref: Some(event_ref('6', KIND_KNOWLEDGE_CLAIM)), + address_ref: None, + external_id: None, + label: Some(label.to_string()), + } +} + +fn relation() -> RadrootsKnowledgeRelation { + RadrootsKnowledgeRelation { + schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + subject: node_ref("cover crops"), + predicate: "supports".to_string(), + object: node_ref("soil structure"), + support_refs: vec![event_ref('7', KIND_KNOWLEDGE_CLAIM)], + author_asserted_confidence: Some("medium".to_string()), + supersedes: Vec::new(), + } +} + +fn review() -> RadrootsKnowledgeReview { + RadrootsKnowledgeReview { + schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + target: RadrootsKnowledgeReviewTarget { + event_id: hex_64('8'), + author_pubkey: hex_64('a'), + kind: KIND_KNOWLEDGE_CLAIM, + address: None, + relays: vec!["wss://relay.radroots.example".to_string()], + review_scope: RadrootsKnowledgeReviewScope::SpecificVersion, + }, + reviewer_role: "peer".to_string(), + verdict: "needs_more_evidence".to_string(), + scores: vec![RadrootsKnowledgeReviewScore { + dimension: "evidence".to_string(), + value: "partial".to_string(), + note: None, + }], + notes: Some("Synthetic review".to_string()), + evidence_refs: vec![event_ref('9', KIND_KNOWLEDGE_SOURCE)], + } +} + +fn field_report() -> RadrootsKnowledgeFieldReport { + RadrootsKnowledgeFieldReport { + schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + report_type: "observation".to_string(), + title: "Field observation".to_string(), + summary: Some("Observed cover crop residue.".to_string()), + context: RadrootsKnowledgeFieldContext { + location_precision: RadrootsKnowledgeLocationPrecision::CoarseGeohash, + public_location: Some(RadrootsKnowledgeLocation { + label: Some("watershed".to_string()), + region: Some("synthetic-region".to_string()), + locality: None, + geohash: Some("c23".to_string()), + }), + private_location_ref: None, + topics: vec!["field".to_string()], + context_tags: vec!["observation".to_string()], + }, + observations: vec![RadrootsKnowledgeObservation { + observation_type: "residue".to_string(), + text: "Residue was visible across beds.".to_string(), + observed_at: Some("2026-07-05".to_string()), + values: vec![RadrootsKnowledgeObservationValue { + key: "coverage".to_string(), + value: "medium".to_string(), + unit: None, + }], + }], + artifact_refs: vec![event_ref('c', 1063)], + related_refs: vec![event_ref('d', KIND_KNOWLEDGE_CLAIM)], + limitations: vec!["single observer".to_string()], + } +} + +#[test] +fn knowledge_codecs_roundtrip_all_contracts() { + let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + validate_event_contract_shape(&article_event, "radroots.wiki.article.v1").unwrap(); + assert_eq!( + wiki_article_from_event(article_event) + .unwrap() + .data + .data + .title, + "Soil health" + ); + + let redirect = RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: article_ref(), + }; + let redirect_event = event_from_parts(wiki_redirect_to_wire_parts(&redirect).unwrap()); + validate_event_contract_shape(&redirect_event, "radroots.wiki.redirect.v1").unwrap(); + assert_eq!( + wiki_redirect_from_event(redirect_event) + .unwrap() + .data + .data + .target + .d_tag, + Some("soil-health".to_string()) + ); + + let merge = RadrootsWikiMergeRequest { + target_article: address_ref(), + destination_pubkey: hex_64('a'), + base_version_event_id: Some(hex_64('e')), + source_version_event_id: hex_64('f'), + explanation: Some("Merge synthetic source".to_string()), + }; + let merge_event = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); + validate_event_contract_shape(&merge_event, "radroots.wiki.merge_request.v1").unwrap(); + assert_eq!( + wiki_merge_request_from_event(merge_event) + .unwrap() + .data + .data + .source_version_event_id, + hex_64('f') + ); + + let source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap()); + validate_event_contract_shape(&source_event, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA).unwrap(); + assert_eq!( + knowledge_source_from_event(source_event) + .unwrap() + .data + .data + .source_type, + "book" + ); + + let claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap()); + validate_event_contract_shape(&claim_event, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA).unwrap(); + assert_eq!( + knowledge_claim_from_event(claim_event) + .unwrap() + .data + .data + .claim_type, + "practice_effect" + ); + + let relation_event = event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap()); + validate_event_contract_shape(&relation_event, RADROOTS_KNOWLEDGE_RELATION_SCHEMA).unwrap(); + assert_eq!( + knowledge_relation_from_event(relation_event) + .unwrap() + .data + .data + .predicate, + "supports" + ); + + let review_event = event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap()); + validate_event_contract_shape(&review_event, RADROOTS_KNOWLEDGE_REVIEW_SCHEMA).unwrap(); + assert_eq!( + knowledge_review_from_event(review_event) + .unwrap() + .data + .data + .verdict, + "needs_more_evidence" + ); + + let report_event = + event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap()); + validate_event_contract_shape(&report_event, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA).unwrap(); + assert_eq!( + knowledge_field_report_from_event(report_event) + .unwrap() + .data + .data + .report_type, + "observation" + ); + + let bounty = RadrootsEvidenceBounty { + schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-bounty".to_string(), + title: "Soil bounty".to_string(), + summary: None, + topics: vec!["soil".to_string()], + target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)], + reward_note: None, + closes_at: None, + }; + let bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty).unwrap()); + validate_event_contract_shape(&bounty_event, RADROOTS_EVIDENCE_BOUNTY_SCHEMA).unwrap(); + assert_eq!( + evidence_bounty_from_event(bounty_event) + .unwrap() + .data + .data + .title, + "Soil bounty" + ); + + let proposal = RadrootsKnowledgeChangeProposal { + schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + target: event_ref('b', KIND_KNOWLEDGE_CLAIM), + proposal_type: "amend".to_string(), + summary: "Clarify scope".to_string(), + rationale: None, + evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)], + supersedes: Vec::new(), + }; + let proposal_event = + event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal).unwrap()); + validate_event_contract_shape(&proposal_event, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA) + .unwrap(); + assert_eq!( + knowledge_change_proposal_from_event(proposal_event) + .unwrap() + .data + .data + .proposal_type, + "amend" + ); + + let attestation = RadrootsContributionAttestation { + schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + contributor_pubkey: hex_64('a'), + contribution_type: "review".to_string(), + subject_refs: vec![event_ref('d', KIND_KNOWLEDGE_REVIEW)], + summary: "Reviewed synthetic claim".to_string(), + evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)], + }; + let attestation_event = + event_from_parts(contribution_attestation_to_wire_parts(&attestation).unwrap()); + validate_event_contract_shape(&attestation_event, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA) + .unwrap(); + assert_eq!( + contribution_attestation_from_event(attestation_event) + .unwrap() + .data + .data + .contribution_type, + "review" + ); +} + +#[test] +fn verified_decode_accepts_signed_claim_and_rejects_mutation() { + let signed = sign_parts(knowledge_claim_to_wire_parts(&claim()).unwrap()); + let decoded = verify_and_decode_radroots_event(signed.clone()).unwrap(); + assert!(matches!(decoded, RadrootsDecodedEvent::KnowledgeClaim(_))); + + let mut mutated = signed; + mutated.content = mutated.content.replace("Cover crops", "Compost"); + let err = verify_and_decode_radroots_event(mutated).unwrap_err(); + assert_eq!(err.code(), "nip01_verification"); + assert!(matches!( + err, + RadrootsDecodeError::Nip01Verification(RadrootsNip01VerificationError::IdMismatch { .. }) + )); +} + +#[test] +fn malformed_knowledge_events_return_stable_decode_codes() { + let mut missing_contract = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap()); + missing_contract + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some("contract")); + let signed = sign_parts(WireEventParts { + kind: missing_contract.kind, + content: missing_contract.content, + tags: missing_contract.tags, + }); + let error = verify_and_decode_radroots_event(signed).unwrap_err(); + assert_eq!(error.code(), "contract_validation"); + + let mut report = + event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&report.content).unwrap(); + value["context"]["latitude"] = serde_json::Value::from("45.0000"); + report.content = serde_json::to_string(&value).unwrap(); + let parsed_error = knowledge_field_report_from_event(report).unwrap_err(); + assert_eq!(parsed_error.code(), "invalid_json"); +}