sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 4638baeffcb0be18c0ee32b799d88422f6edb7d8
parent ede93b05499af38054f92f827207a37a2d190cdc
Author: triesap <tyson@radroots.org>
Date:   Sun,  5 Jul 2026 06:29:56 +0000

sdk: add knowledge event surface

Diffstat:
MCargo.lock | 2++
Mcrates/sdk/Cargo.toml | 12++++++++++++
Mcrates/sdk/README | 4++++
Acrates/sdk/examples/sdk_v1_knowledge_prepare.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/knowledge.rs | 560+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 38++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/knowledge_public_api.rs | 345+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 15++++++++++++++-
Atools/xtask/src/smoke.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1220 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1806,11 +1806,13 @@ dependencies = [ name = "radroots_events_codec" version = "0.1.0-alpha.2" dependencies = [ + "hex", "nostr", "radroots_core", "radroots_events", "serde", "serde_json", + "sha2", ] [[package]] diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -23,6 +23,13 @@ serde_json = [ "radroots_trade/serde_json", ] nostr = ["radroots_events_codec/nostr"] +knowledge = [ + "serde", + "serde_json", + "radroots_events/knowledge-nip54", + "radroots_events_codec/contract-manifest", + "radroots_events_codec/knowledge-nip54", +] identity-models = [ "dep:radroots_identity", "radroots_identity/profile", @@ -148,6 +155,11 @@ path = "examples/sdk_v1_listing_prepare.rs" required-features = ["runtime"] [[example]] +name = "sdk_v1_knowledge_prepare" +path = "examples/sdk_v1_knowledge_prepare.rs" +required-features = ["knowledge"] + +[[example]] name = "sdk_v1_local_enqueue_and_mock_sync" path = "examples/sdk_v1_local_enqueue_and_mock_sync.rs" required-features = ["runtime", "signer-adapters"] diff --git a/crates/sdk/README b/crates/sdk/README @@ -82,12 +82,16 @@ Compile the SDK v1 product examples with: ```bash cargo check -p radroots_sdk --example sdk_v1_listing_prepare --features runtime +cargo check -p radroots_sdk --example sdk_v1_knowledge_prepare --features knowledge cargo check -p radroots_sdk --example sdk_v1_local_enqueue_and_mock_sync --features runtime,signer-adapters cargo check -p radroots_sdk --example sdk_v1_myc_nip46_signer_setup --features runtime,signer-adapters ``` `sdk_v1_listing_prepare` shows `RadrootsClient::builder()`, `ListingPreparePublishRequest`, and `ListingPublishPlan`. +`sdk_v1_knowledge_prepare` shows `radroots_sdk::knowledge`, typed event builders, frozen draft +preparation, signed-event verification, decoded event matching, and manifest hash access without +local runtime storage. `sdk_v1_local_enqueue_and_mock_sync` shows localhost relay target selection, configured local-key signing, prepared listing enqueue, `push_outbox_with_adapter(...)` with a mock relay adapter, and `TradeStatusRequest`. `sdk_v1_myc_nip46_signer_setup` shows Myc NIP-46 signer provider setup and diff --git a/crates/sdk/examples/sdk_v1_knowledge_prepare.rs b/crates/sdk/examples/sdk_v1_knowledge_prepare.rs @@ -0,0 +1,101 @@ +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use radroots_sdk::knowledge::prelude::*; + +const SECRET_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; +const CREATED_AT: u32 = 1_800_000_000; +const RELAY: &str = "wss://relay.radroots.example"; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let claim = RadrootsKnowledgeClaim { + schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_owned(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + claim_type: "practice_effect".to_owned(), + text: "Cover crops improve soil structure.".to_owned(), + 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_owned()], + quote_hash: Some(hex_64('5')), + chunk_id: Some("chunk-1".to_owned()), + }], + topics: vec!["cover-crops".to_owned()], + applies_to: vec!["local-food".to_owned()], + author_asserted_confidence: Some("medium".to_owned()), + supersedes: Vec::new(), + }; + + let parts = KnowledgeEventBuilder::new().knowledge_claim(&claim)?; + let draft = KnowledgeDraftBuilder::new(public_key_hex(), CREATED_AT).knowledge_claim(&claim)?; + let signed = sign_parts(parts)?; + let decoded = KnowledgeCodec::new().verify_and_decode_radroots_event(signed)?; + let manifest = contract_manifest(); + let manifest_hash = contract_manifest_sha256()?; + + assert_eq!(draft.contract_id, KNOWLEDGE_CLAIM_CONTRACT_ID); + assert_eq!(manifest.contract_count, 11); + assert_eq!(manifest_hash.len(), 64); + + match decoded { + RadrootsDecodedEvent::KnowledgeClaim(parsed) => { + assert_eq!(parsed.data.data.text, claim.text); + } + _ => return Err("expected knowledge claim".into()), + } + + println!( + "prepared knowledge claim draft: {}", + draft.expected_event_id + ); + println!("knowledge manifest sha256: {manifest_hash}"); + Ok(()) +} + +fn sign_parts(parts: WireEventParts) -> Result<RadrootsNostrEvent, Box<dyn std::error::Error>> { + let tags = parts + .tags + .into_iter() + .map(Tag::parse) + .collect::<Result<Vec<_>, _>>()?; + let keys = Keys::parse(SECRET_KEY_HEX)?; + let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(u64::from(CREATED_AT))) + .sign_with_keys(&keys)?; + Ok(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 public_key_hex() -> String { + Keys::parse(SECRET_KEY_HEX) + .expect("keys") + .public_key() + .to_hex() +} + +fn hex_64(character: char) -> String { + character.to_string().repeat(64) +} + +fn event_ref(character: char, kind: u32) -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: hex_64(character), + author: hex_64('a'), + kind, + d_tag: None, + relays: Some(vec![RELAY.to_owned()]), + } +} diff --git a/crates/sdk/src/knowledge.rs b/crates/sdk/src/knowledge.rs @@ -0,0 +1,560 @@ +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString}; +#[cfg(feature = "std")] +use std::string::{String, ToString}; + +use core::fmt; + +pub use radroots_events::{ + RadrootsNostrEvent, RadrootsNostrEventRef, + draft::{RadrootsDraftError, RadrootsFrozenEventDraft}, + kinds::{ + KIND_FILE_METADATA, 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, + }, + 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, RADROOTS_WIKI_D_TAG_MAX_LEN, RadrootsAddressableRef, + RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeCitationSpan, RadrootsKnowledgeClaim, RadrootsKnowledgeFieldContext, + RadrootsKnowledgeFieldReport, RadrootsKnowledgeLocation, + RadrootsKnowledgeLocationPrecision, RadrootsKnowledgeNodeRef, RadrootsKnowledgeObservation, + RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, + RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore, RadrootsKnowledgeReviewTarget, + RadrootsKnowledgeSource, RadrootsRightsAssertion, RadrootsWikiArticle, + RadrootsWikiDTagError, RadrootsWikiMergeRequest, RadrootsWikiRedirect, + normalize_wiki_d_tag, validate_wiki_d_tag, + }, +}; +pub use radroots_events_codec::{ + RADROOTS_KNOWLEDGE_CONTRACT_MANIFEST_SCHEMA_VERSION, + error::RadrootsEncodeError, + manifest::{ + RadrootsKnowledgeContractManifest, RadrootsKnowledgeContractManifestEntry, + RadrootsKnowledgeManifestCodecSupport, RadrootsKnowledgeManifestDiscriminator, + RadrootsKnowledgeManifestTagContract, + }, + verification::{ + RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent, + RadrootsIdVerifiedEvent, RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, + }, + wire::WireEventParts, +}; + +use radroots_events_codec::{ + contract_manifest_json as codec_contract_manifest_json, + contract_manifest_sha256 as codec_contract_manifest_sha256, + knowledge::{ + 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_contract_manifest, verify_and_decode_radroots_event as codec_verify_and_decode, + wire::to_frozen_draft, +}; + +pub const WIKI_ARTICLE_CONTRACT_ID: &str = "radroots.wiki.article.v1"; +pub const WIKI_REDIRECT_CONTRACT_ID: &str = "radroots.wiki.redirect.v1"; +pub const WIKI_MERGE_REQUEST_CONTRACT_ID: &str = "radroots.wiki.merge_request.v1"; +pub const KNOWLEDGE_SOURCE_CONTRACT_ID: &str = RADROOTS_KNOWLEDGE_SOURCE_SCHEMA; +pub const KNOWLEDGE_CLAIM_CONTRACT_ID: &str = RADROOTS_KNOWLEDGE_CLAIM_SCHEMA; +pub const KNOWLEDGE_RELATION_CONTRACT_ID: &str = RADROOTS_KNOWLEDGE_RELATION_SCHEMA; +pub const KNOWLEDGE_REVIEW_CONTRACT_ID: &str = RADROOTS_KNOWLEDGE_REVIEW_SCHEMA; +pub const KNOWLEDGE_FIELD_REPORT_CONTRACT_ID: &str = RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA; + +#[derive(Debug)] +pub enum RadrootsSdkKnowledgeError { + Encode(RadrootsEncodeError), + Draft(RadrootsDraftError), + Decode(RadrootsDecodeError), + Manifest(serde_json::Error), +} + +impl RadrootsSdkKnowledgeError { + pub const fn code(&self) -> &'static str { + match self { + Self::Encode(_) => "knowledge_encode", + Self::Draft(_) => "knowledge_draft", + Self::Decode(_) => "knowledge_decode", + Self::Manifest(_) => "knowledge_manifest", + } + } + + pub fn inner_code(&self) -> &'static str { + match self { + Self::Encode(error) => error.code(), + Self::Draft(error) => draft_error_code(error), + Self::Decode(error) => error.code(), + Self::Manifest(_) => "json", + } + } +} + +impl fmt::Display for RadrootsSdkKnowledgeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Encode(_) => formatter.write_str("knowledge event encoding failed"), + Self::Draft(_) => formatter.write_str("knowledge event draft preparation failed"), + Self::Decode(_) => formatter.write_str("knowledge event verification or decode failed"), + Self::Manifest(_) => { + formatter.write_str("knowledge contract manifest rendering failed") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsSdkKnowledgeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Encode(error) => Some(error), + Self::Draft(error) => Some(error), + Self::Decode(error) => Some(error), + Self::Manifest(error) => Some(error), + } + } +} + +impl From<RadrootsEncodeError> for RadrootsSdkKnowledgeError { + fn from(value: RadrootsEncodeError) -> Self { + Self::Encode(value) + } +} + +impl From<RadrootsDraftError> for RadrootsSdkKnowledgeError { + fn from(value: RadrootsDraftError) -> Self { + Self::Draft(value) + } +} + +impl From<RadrootsDecodeError> for RadrootsSdkKnowledgeError { + fn from(value: RadrootsDecodeError) -> Self { + Self::Decode(value) + } +} + +impl From<serde_json::Error> for RadrootsSdkKnowledgeError { + fn from(value: serde_json::Error) -> Self { + Self::Manifest(value) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct KnowledgeEventBuilder; + +impl KnowledgeEventBuilder { + pub const fn new() -> Self { + Self + } + + pub fn wiki_article( + &self, + article: &RadrootsWikiArticle, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_wiki_article_event(article) + } + + pub fn wiki_redirect( + &self, + redirect: &RadrootsWikiRedirect, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_wiki_redirect_event(redirect) + } + + pub fn wiki_merge_request( + &self, + request: &RadrootsWikiMergeRequest, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_wiki_merge_request_event(request) + } + + pub fn knowledge_source( + &self, + source: &RadrootsKnowledgeSource, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_knowledge_source_event(source) + } + + pub fn knowledge_claim( + &self, + claim: &RadrootsKnowledgeClaim, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_knowledge_claim_event(claim) + } + + pub fn knowledge_relation( + &self, + relation: &RadrootsKnowledgeRelation, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_knowledge_relation_event(relation) + } + + pub fn knowledge_review( + &self, + review: &RadrootsKnowledgeReview, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_knowledge_review_event(review) + } + + pub fn knowledge_field_report( + &self, + report: &RadrootsKnowledgeFieldReport, + ) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + build_knowledge_field_report_event(report) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KnowledgeDraftBuilder { + expected_pubkey: String, + created_at: u32, +} + +impl KnowledgeDraftBuilder { + pub fn new(expected_pubkey: impl AsRef<str>, created_at: u32) -> Self { + Self { + expected_pubkey: expected_pubkey.as_ref().to_string(), + created_at, + } + } + + pub fn expected_pubkey(&self) -> &str { + self.expected_pubkey.as_str() + } + + pub const fn created_at(&self) -> u32 { + self.created_at + } + + pub fn wiki_article( + &self, + article: &RadrootsWikiArticle, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_wiki_article_draft(article, self.expected_pubkey(), self.created_at) + } + + pub fn wiki_redirect( + &self, + redirect: &RadrootsWikiRedirect, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_wiki_redirect_draft(redirect, self.expected_pubkey(), self.created_at) + } + + pub fn wiki_merge_request( + &self, + request: &RadrootsWikiMergeRequest, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_wiki_merge_request_draft(request, self.expected_pubkey(), self.created_at) + } + + pub fn knowledge_source( + &self, + source: &RadrootsKnowledgeSource, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_knowledge_source_draft(source, self.expected_pubkey(), self.created_at) + } + + pub fn knowledge_claim( + &self, + claim: &RadrootsKnowledgeClaim, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_knowledge_claim_draft(claim, self.expected_pubkey(), self.created_at) + } + + pub fn knowledge_relation( + &self, + relation: &RadrootsKnowledgeRelation, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_knowledge_relation_draft(relation, self.expected_pubkey(), self.created_at) + } + + pub fn knowledge_review( + &self, + review: &RadrootsKnowledgeReview, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_knowledge_review_draft(review, self.expected_pubkey(), self.created_at) + } + + pub fn knowledge_field_report( + &self, + report: &RadrootsKnowledgeFieldReport, + ) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_knowledge_field_report_draft(report, self.expected_pubkey(), self.created_at) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct KnowledgeCodec; + +impl KnowledgeCodec { + pub const fn new() -> Self { + Self + } + + pub fn verify_and_decode_radroots_event( + &self, + event: RadrootsNostrEvent, + ) -> Result<RadrootsDecodedEvent, RadrootsSdkKnowledgeError> { + verify_and_decode_radroots_event(event) + } + + pub fn contract_manifest(&self) -> RadrootsKnowledgeContractManifest { + contract_manifest() + } + + pub fn contract_manifest_json(&self) -> Result<String, RadrootsSdkKnowledgeError> { + contract_manifest_json() + } + + pub fn contract_manifest_sha256(&self) -> Result<String, RadrootsSdkKnowledgeError> { + contract_manifest_sha256() + } +} + +pub fn build_wiki_article_event( + article: &RadrootsWikiArticle, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(wiki_article_to_wire_parts(article)?) +} + +pub fn build_wiki_redirect_event( + redirect: &RadrootsWikiRedirect, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(wiki_redirect_to_wire_parts(redirect)?) +} + +pub fn build_wiki_merge_request_event( + request: &RadrootsWikiMergeRequest, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(wiki_merge_request_to_wire_parts(request)?) +} + +pub fn build_knowledge_source_event( + source: &RadrootsKnowledgeSource, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(knowledge_source_to_wire_parts(source)?) +} + +pub fn build_knowledge_claim_event( + claim: &RadrootsKnowledgeClaim, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(knowledge_claim_to_wire_parts(claim)?) +} + +pub fn build_knowledge_relation_event( + relation: &RadrootsKnowledgeRelation, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(knowledge_relation_to_wire_parts(relation)?) +} + +pub fn build_knowledge_review_event( + review: &RadrootsKnowledgeReview, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(knowledge_review_to_wire_parts(review)?) +} + +pub fn build_knowledge_field_report_event( + report: &RadrootsKnowledgeFieldReport, +) -> Result<WireEventParts, RadrootsSdkKnowledgeError> { + Ok(knowledge_field_report_to_wire_parts(report)?) +} + +pub fn prepare_wiki_article_draft( + article: &RadrootsWikiArticle, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_wiki_article_event(article)?, + WIKI_ARTICLE_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_wiki_redirect_draft( + redirect: &RadrootsWikiRedirect, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_wiki_redirect_event(redirect)?, + WIKI_REDIRECT_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_wiki_merge_request_draft( + request: &RadrootsWikiMergeRequest, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_wiki_merge_request_event(request)?, + WIKI_MERGE_REQUEST_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_knowledge_source_draft( + source: &RadrootsKnowledgeSource, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_knowledge_source_event(source)?, + KNOWLEDGE_SOURCE_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_knowledge_claim_draft( + claim: &RadrootsKnowledgeClaim, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_knowledge_claim_event(claim)?, + KNOWLEDGE_CLAIM_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_knowledge_relation_draft( + relation: &RadrootsKnowledgeRelation, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_knowledge_relation_event(relation)?, + KNOWLEDGE_RELATION_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_knowledge_review_draft( + review: &RadrootsKnowledgeReview, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_knowledge_review_event(review)?, + KNOWLEDGE_REVIEW_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn prepare_knowledge_field_report_draft( + report: &RadrootsKnowledgeFieldReport, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + prepare_draft( + build_knowledge_field_report_event(report)?, + KNOWLEDGE_FIELD_REPORT_CONTRACT_ID, + expected_pubkey, + created_at, + ) +} + +pub fn verify_and_decode_radroots_event( + event: RadrootsNostrEvent, +) -> Result<RadrootsDecodedEvent, RadrootsSdkKnowledgeError> { + Ok(codec_verify_and_decode(event)?) +} + +pub fn contract_manifest() -> RadrootsKnowledgeContractManifest { + knowledge_contract_manifest() +} + +pub fn contract_manifest_json() -> Result<String, RadrootsSdkKnowledgeError> { + Ok(codec_contract_manifest_json()?) +} + +pub fn contract_manifest_sha256() -> Result<String, RadrootsSdkKnowledgeError> { + Ok(codec_contract_manifest_sha256()?) +} + +fn prepare_draft( + parts: WireEventParts, + contract_id: &'static str, + expected_pubkey: impl AsRef<str>, + created_at: u32, +) -> Result<RadrootsFrozenEventDraft, RadrootsSdkKnowledgeError> { + Ok(to_frozen_draft( + parts, + contract_id, + expected_pubkey, + created_at, + )?) +} + +fn draft_error_code(error: &RadrootsDraftError) -> &'static str { + match error { + RadrootsDraftError::UnknownContract(_) => "unknown_contract", + RadrootsDraftError::ContractKindMismatch { .. } => "contract_kind_mismatch", + RadrootsDraftError::SignedEventPubkeyMismatch { .. } => "signed_event_pubkey_mismatch", + RadrootsDraftError::SignedEventIdMismatch { .. } => "signed_event_id_mismatch", + RadrootsDraftError::SignedEventCreatedAtMismatch { .. } => { + "signed_event_created_at_mismatch" + } + RadrootsDraftError::SignedEventKindMismatch { .. } => "signed_event_kind_mismatch", + RadrootsDraftError::SignedEventTagsMismatch { .. } => "signed_event_tags_mismatch", + RadrootsDraftError::SignedEventContentMismatch { .. } => "signed_event_content_mismatch", + RadrootsDraftError::SignedEventComputedIdMismatch { .. } => { + "signed_event_computed_id_mismatch" + } + RadrootsDraftError::IdParse(_) => "id_parse", + RadrootsDraftError::JsonString(_) => "json_string", + } +} + +pub mod prelude { + pub use super::{ + KIND_FILE_METADATA, 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, KNOWLEDGE_CLAIM_CONTRACT_ID, + KNOWLEDGE_FIELD_REPORT_CONTRACT_ID, KNOWLEDGE_RELATION_CONTRACT_ID, + KNOWLEDGE_REVIEW_CONTRACT_ID, KNOWLEDGE_SOURCE_CONTRACT_ID, KnowledgeCodec, + KnowledgeDraftBuilder, KnowledgeEventBuilder, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, + RADROOTS_EVIDENCE_BOUNTY_SCHEMA, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, + RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, RADROOTS_KNOWLEDGE_CONTRACT_MANIFEST_SCHEMA_VERSION, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RADROOTS_WIKI_D_TAG_MAX_LEN, RadrootsAddressableRef, + RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent, + RadrootsDraftError, RadrootsEncodeError, RadrootsFrozenEventDraft, RadrootsIdVerifiedEvent, + RadrootsKnowledgeChangeProposal, RadrootsKnowledgeCitationSpan, RadrootsKnowledgeClaim, + RadrootsKnowledgeContractManifest, RadrootsKnowledgeContractManifestEntry, + RadrootsKnowledgeFieldContext, RadrootsKnowledgeFieldReport, RadrootsKnowledgeLocation, + RadrootsKnowledgeLocationPrecision, RadrootsKnowledgeManifestCodecSupport, + RadrootsKnowledgeManifestDiscriminator, RadrootsKnowledgeManifestTagContract, + RadrootsKnowledgeNodeRef, RadrootsKnowledgeObservation, RadrootsKnowledgeObservationValue, + RadrootsKnowledgeRelation, RadrootsKnowledgeReview, RadrootsKnowledgeReviewScope, + RadrootsKnowledgeReviewScore, RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, + RadrootsNip01VerificationError, RadrootsNostrEvent, RadrootsNostrEventRef, + RadrootsRightsAssertion, RadrootsSdkKnowledgeError, RadrootsSignatureVerifiedEvent, + RadrootsWikiArticle, RadrootsWikiDTagError, RadrootsWikiMergeRequest, RadrootsWikiRedirect, + WIKI_ARTICLE_CONTRACT_ID, WIKI_MERGE_REQUEST_CONTRACT_ID, WIKI_REDIRECT_CONTRACT_ID, + WireEventParts, build_knowledge_claim_event, build_knowledge_field_report_event, + build_knowledge_relation_event, build_knowledge_review_event, build_knowledge_source_event, + build_wiki_article_event, build_wiki_merge_request_event, build_wiki_redirect_event, + contract_manifest, contract_manifest_json, contract_manifest_sha256, normalize_wiki_d_tag, + prepare_knowledge_claim_draft, prepare_knowledge_field_report_draft, + prepare_knowledge_relation_draft, prepare_knowledge_review_draft, + prepare_knowledge_source_draft, prepare_wiki_article_draft, + prepare_wiki_merge_request_draft, prepare_wiki_redirect_draft, validate_wiki_d_tag, + verify_and_decode_radroots_event, + }; +} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -27,6 +27,8 @@ mod geonames; mod idempotency; #[cfg(feature = "identity-models")] pub mod identity; +#[cfg(feature = "knowledge")] +pub mod knowledge; #[cfg(feature = "runtime")] mod listings_runtime; #[cfg(feature = "runtime")] @@ -91,6 +93,42 @@ pub use crate::geonames::{ pub use crate::idempotency::{ SDK_IDEMPOTENCY_KEY_MAX_LEN, SdkIdempotencyKey, SdkTradeIdempotencyRecord, }; +#[cfg(feature = "knowledge")] +pub use crate::knowledge::{ + KIND_FILE_METADATA, 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, KNOWLEDGE_CLAIM_CONTRACT_ID, KNOWLEDGE_FIELD_REPORT_CONTRACT_ID, + KNOWLEDGE_RELATION_CONTRACT_ID, KNOWLEDGE_REVIEW_CONTRACT_ID, KNOWLEDGE_SOURCE_CONTRACT_ID, + KnowledgeCodec, KnowledgeDraftBuilder, KnowledgeEventBuilder, + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + RADROOTS_KNOWLEDGE_CONTRACT_MANIFEST_SCHEMA_VERSION, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, + RADROOTS_KNOWLEDGE_RELATION_SCHEMA, RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, + RADROOTS_KNOWLEDGE_SCHEMA_VERSION, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, + RADROOTS_WIKI_D_TAG_MAX_LEN, RadrootsAddressableRef, RadrootsContractValidatedEvent, + RadrootsDecodeError, RadrootsDecodedEvent, RadrootsDraftError, RadrootsEncodeError, + RadrootsFrozenEventDraft, RadrootsIdVerifiedEvent, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeCitationSpan, RadrootsKnowledgeClaim, RadrootsKnowledgeContractManifest, + RadrootsKnowledgeContractManifestEntry, RadrootsKnowledgeFieldContext, + RadrootsKnowledgeFieldReport, RadrootsKnowledgeLocation, RadrootsKnowledgeLocationPrecision, + RadrootsKnowledgeManifestCodecSupport, RadrootsKnowledgeManifestDiscriminator, + RadrootsKnowledgeManifestTagContract, RadrootsKnowledgeNodeRef, RadrootsKnowledgeObservation, + RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, + RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore, RadrootsKnowledgeReviewTarget, + RadrootsKnowledgeSource, RadrootsNip01VerificationError, RadrootsNostrEvent, + RadrootsNostrEventRef, RadrootsRightsAssertion, RadrootsSdkKnowledgeError, + RadrootsSignatureVerifiedEvent, RadrootsWikiArticle, RadrootsWikiDTagError, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, WIKI_ARTICLE_CONTRACT_ID, + WIKI_MERGE_REQUEST_CONTRACT_ID, WIKI_REDIRECT_CONTRACT_ID, WireEventParts, + build_knowledge_claim_event, build_knowledge_field_report_event, + build_knowledge_relation_event, build_knowledge_review_event, build_knowledge_source_event, + build_wiki_article_event, build_wiki_merge_request_event, build_wiki_redirect_event, + contract_manifest, contract_manifest_json, contract_manifest_sha256, normalize_wiki_d_tag, + prepare_knowledge_claim_draft, prepare_knowledge_field_report_draft, + prepare_knowledge_relation_draft, prepare_knowledge_review_draft, + prepare_knowledge_source_draft, prepare_wiki_article_draft, prepare_wiki_merge_request_draft, + prepare_wiki_redirect_draft, validate_wiki_d_tag, verify_and_decode_radroots_event, +}; #[cfg(feature = "runtime")] pub use crate::listings_runtime::{ LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, ListingEnqueueReceipt, diff --git a/crates/sdk/tests/knowledge_public_api.rs b/crates/sdk/tests/knowledge_public_api.rs @@ -0,0 +1,345 @@ +#![cfg(feature = "knowledge")] + +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use radroots_sdk::knowledge::prelude::*; + +const SECRET_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; +const CREATED_AT: u32 = 1_800_000_000; +const RELAY: &str = "wss://relay.radroots.example"; + +#[test] +fn knowledge_prelude_builds_mvp_wire_parts_without_codec_imports() { + let builder = KnowledgeEventBuilder::new(); + + let article = builder.wiki_article(&wiki_article()).expect("article"); + let redirect = builder.wiki_redirect(&wiki_redirect()).expect("redirect"); + let merge_request = builder + .wiki_merge_request(&wiki_merge_request()) + .expect("merge request"); + let source = builder + .knowledge_source(&knowledge_source()) + .expect("source"); + let claim = builder.knowledge_claim(&knowledge_claim()).expect("claim"); + let relation = builder + .knowledge_relation(&knowledge_relation()) + .expect("relation"); + let review = builder + .knowledge_review(&knowledge_review()) + .expect("review"); + let field_report = builder + .knowledge_field_report(&knowledge_field_report()) + .expect("field report"); + + assert_eq!(article.kind, KIND_WIKI_ARTICLE); + assert_eq!(redirect.kind, KIND_WIKI_REDIRECT); + assert_eq!(merge_request.kind, KIND_WIKI_MERGE_REQUEST); + assert_eq!(source.kind, KIND_KNOWLEDGE_SOURCE); + assert_eq!(claim.kind, KIND_KNOWLEDGE_CLAIM); + assert_eq!(relation.kind, KIND_KNOWLEDGE_RELATION); + assert_eq!(review.kind, KIND_KNOWLEDGE_REVIEW); + assert_eq!(field_report.kind, KIND_KNOWLEDGE_FIELD_REPORT); +} + +#[test] +fn knowledge_draft_builder_freezes_mvp_drafts_without_runtime() { + let draft_builder = KnowledgeDraftBuilder::new(public_key_hex(), CREATED_AT); + + let article = draft_builder + .wiki_article(&wiki_article()) + .expect("article draft"); + let source = draft_builder + .knowledge_source(&knowledge_source()) + .expect("source draft"); + let claim = draft_builder + .knowledge_claim(&knowledge_claim()) + .expect("claim draft"); + let relation = draft_builder + .knowledge_relation(&knowledge_relation()) + .expect("relation draft"); + let review = draft_builder + .knowledge_review(&knowledge_review()) + .expect("review draft"); + let field_report = draft_builder + .knowledge_field_report(&knowledge_field_report()) + .expect("field report draft"); + + assert_eq!(article.contract_id, WIKI_ARTICLE_CONTRACT_ID); + assert_eq!(source.contract_id, KNOWLEDGE_SOURCE_CONTRACT_ID); + assert_eq!(claim.contract_id, KNOWLEDGE_CLAIM_CONTRACT_ID); + assert_eq!(relation.contract_id, KNOWLEDGE_RELATION_CONTRACT_ID); + assert_eq!(review.contract_id, KNOWLEDGE_REVIEW_CONTRACT_ID); + assert_eq!(field_report.contract_id, KNOWLEDGE_FIELD_REPORT_CONTRACT_ID); + assert_eq!(claim.expected_pubkey, public_key_hex()); + assert_eq!(claim.created_at, CREATED_AT); + assert_eq!(claim.kind, KIND_KNOWLEDGE_CLAIM); +} + +#[test] +fn knowledge_codec_exposes_manifest_and_verified_decode() { + let codec = KnowledgeCodec::new(); + let manifest = codec.contract_manifest(); + + assert_eq!( + manifest.schema_version, + RADROOTS_KNOWLEDGE_CONTRACT_MANIFEST_SCHEMA_VERSION + ); + assert_eq!(manifest.contract_count, 11); + assert!( + manifest + .contracts + .iter() + .any(|contract| contract.contract_id == KNOWLEDGE_CLAIM_CONTRACT_ID) + ); + + let signed = sign_parts( + KnowledgeEventBuilder::new() + .knowledge_claim(&knowledge_claim()) + .expect("claim parts"), + ); + let decoded = codec + .verify_and_decode_radroots_event(signed) + .expect("decoded claim"); + + match decoded { + RadrootsDecodedEvent::KnowledgeClaim(parsed) => { + assert_eq!(parsed.data.data.text, "Cover crops improve soil structure."); + } + _ => panic!("expected knowledge claim"), + } + + let sha256 = codec.contract_manifest_sha256().expect("manifest sha256"); + assert_eq!(sha256.len(), 64); +} + +#[test] +fn knowledge_errors_expose_stable_codes() { + let mut article = wiki_article(); + article.d_tag = "Soil Health".to_owned(); + let error = build_wiki_article_event(&article).expect_err("invalid d tag"); + + assert_eq!(error.code(), "knowledge_encode"); + assert_eq!(error.inner_code(), "invalid_field"); + assert!(!error.to_string().contains(article.content_djot.as_str())); +} + +fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent { + let tags = parts + .tags + .into_iter() + .map(Tag::parse) + .collect::<Result<Vec<_>, _>>() + .expect("tags"); + let keys = Keys::parse(SECRET_KEY_HEX).expect("keys"); + let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(u64::from(CREATED_AT))) + .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 public_key_hex() -> String { + Keys::parse(SECRET_KEY_HEX) + .expect("keys") + .public_key() + .to_hex() +} + +fn hex_64(character: char) -> String { + character.to_string().repeat(64) +} + +fn event_ref(character: char, kind: u32) -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: hex_64(character), + author: hex_64('a'), + kind, + d_tag: None, + relays: Some(vec![RELAY.to_owned()]), + } +} + +fn article_ref() -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: hex_64('b'), + author: hex_64('a'), + kind: KIND_WIKI_ARTICLE, + d_tag: Some("soil-health".to_owned()), + relays: Some(vec![RELAY.to_owned()]), + } +} + +fn address_ref() -> RadrootsAddressableRef { + RadrootsAddressableRef { + kind: KIND_WIKI_ARTICLE, + pubkey: hex_64('a'), + d_tag: "soil-health".to_owned(), + relays: vec![RELAY.to_owned()], + } +} + +fn wiki_article() -> RadrootsWikiArticle { + RadrootsWikiArticle { + d_tag: "soil-health".to_owned(), + title: "Soil health".to_owned(), + content_djot: "# Soil health".to_owned(), + summary: Some("Living soil basics".to_owned()), + topics: vec!["soil".to_owned(), "local-food".to_owned()], + references: vec![event_ref('1', KIND_KNOWLEDGE_SOURCE)], + forked_from: Vec::new(), + deferred_to: None, + } +} + +fn wiki_redirect() -> RadrootsWikiRedirect { + RadrootsWikiRedirect { + d_tag: "soil".to_owned(), + target: article_ref(), + } +} + +fn wiki_merge_request() -> RadrootsWikiMergeRequest { + 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 soil article updates".to_owned()), + } +} + +fn knowledge_source() -> RadrootsKnowledgeSource { + RadrootsKnowledgeSource { + schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_owned(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-source".to_owned(), + title: "Soil Source".to_owned(), + source_type: "book".to_owned(), + authors: vec!["A. Example".to_owned()], + publisher: Some("Radroots Synthetic Press".to_owned()), + publication_year: Some(2026), + edition: None, + canonical_url: Some("https://source.example.test/soil-source".to_owned()), + artifact_refs: vec![event_ref('3', KIND_FILE_METADATA)], + author_asserted_rights: None, + topics: vec!["soil".to_owned()], + summary: Some("Synthetic source for SDK coverage".to_owned()), + } +} + +fn knowledge_claim() -> RadrootsKnowledgeClaim { + RadrootsKnowledgeClaim { + schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_owned(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + claim_type: "practice_effect".to_owned(), + text: "Cover crops improve soil structure.".to_owned(), + 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_owned()], + quote_hash: Some(hex_64('5')), + chunk_id: Some("chunk-1".to_owned()), + }], + topics: vec!["cover-crops".to_owned()], + applies_to: vec!["local-food".to_owned()], + author_asserted_confidence: Some("medium".to_owned()), + supersedes: Vec::new(), + } +} + +fn knowledge_node_ref(label: &str) -> RadrootsKnowledgeNodeRef { + RadrootsKnowledgeNodeRef { + node_type: "event".to_owned(), + event_ref: Some(event_ref('6', KIND_KNOWLEDGE_CLAIM)), + address_ref: None, + external_id: None, + label: Some(label.to_owned()), + } +} + +fn knowledge_relation() -> RadrootsKnowledgeRelation { + RadrootsKnowledgeRelation { + schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_owned(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + subject: knowledge_node_ref("cover crops"), + predicate: "supports".to_owned(), + object: knowledge_node_ref("soil structure"), + support_refs: vec![event_ref('7', KIND_KNOWLEDGE_CLAIM)], + author_asserted_confidence: Some("medium".to_owned()), + supersedes: Vec::new(), + } +} + +fn knowledge_review() -> RadrootsKnowledgeReview { + RadrootsKnowledgeReview { + schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_owned(), + 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![RELAY.to_owned()], + review_scope: RadrootsKnowledgeReviewScope::SpecificVersion, + }, + reviewer_role: "peer".to_owned(), + verdict: "needs_more_evidence".to_owned(), + scores: vec![RadrootsKnowledgeReviewScore { + dimension: "evidence".to_owned(), + value: "partial".to_owned(), + note: None, + }], + notes: Some("Synthetic review".to_owned()), + evidence_refs: vec![event_ref('9', KIND_KNOWLEDGE_SOURCE)], + } +} + +fn knowledge_field_report() -> RadrootsKnowledgeFieldReport { + RadrootsKnowledgeFieldReport { + schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_owned(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + report_type: "observation".to_owned(), + title: "Field observation".to_owned(), + summary: Some("Observed cover crop residue.".to_owned()), + context: RadrootsKnowledgeFieldContext { + location_precision: RadrootsKnowledgeLocationPrecision::CoarseGeohash, + public_location: Some(RadrootsKnowledgeLocation { + label: Some("watershed".to_owned()), + region: Some("synthetic-region".to_owned()), + locality: None, + geohash: Some("c23".to_owned()), + }), + private_location_ref: None, + topics: vec!["field".to_owned()], + context_tags: vec!["observation".to_owned()], + }, + observations: vec![RadrootsKnowledgeObservation { + observation_type: "residue".to_owned(), + text: "Residue was visible across beds.".to_owned(), + observed_at: Some("2026-07-05".to_owned()), + values: vec![RadrootsKnowledgeObservationValue { + key: "coverage".to_owned(), + value: "medium".to_owned(), + unit: None, + }], + }], + artifact_refs: vec![event_ref('c', KIND_FILE_METADATA)], + related_refs: vec![event_ref('d', KIND_KNOWLEDGE_CLAIM)], + limitations: vec!["single observer".to_owned()], + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -10,6 +10,7 @@ mod manifest; mod output; mod package_matrix; mod package_metadata; +mod smoke; mod ts; mod wasm; mod wasm_declarations; @@ -20,6 +21,7 @@ enum CommandAction<'a> { GeneratePackageMetadata, Coverage(&'a [String]), Check, + Smoke(&'a [String]), } fn main() { @@ -37,6 +39,7 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { CommandAction::GeneratePackageMetadata => generate::generate_package_metadata(), CommandAction::Coverage(rest) => coverage::run(rest), CommandAction::Check => check::check(), + CommandAction::Smoke(rest) => smoke::run(rest), } } @@ -53,13 +56,14 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { } [command, rest @ ..] if command == "coverage" => Ok(CommandAction::Coverage(rest)), [command] if command == "check" => Ok(CommandAction::Check), + [command, rest @ ..] if command == "smoke" => Ok(CommandAction::Smoke(rest)), [] => Err(usage()), _ => Err(usage()), } } fn usage() -> String { - "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask coverage run" + "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke knowledge-rust-local | cargo xtask coverage run" .to_owned() } @@ -104,6 +108,15 @@ mod tests { } #[test] + fn accepts_smoke() { + let args = ["smoke".to_owned(), "knowledge-rust-local".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::Smoke(rest) if rest == ["knowledge-rust-local"] + )); + } + + #[test] fn accepts_coverage_run() { let args = ["coverage".to_owned(), "run".to_owned()]; assert!(matches!( diff --git a/tools/xtask/src/smoke.rs b/tools/xtask/src/smoke.rs @@ -0,0 +1,144 @@ +use std::{fs, path::Path, process::Command}; + +use crate::fs::workspace_root; + +pub fn run(args: &[String]) -> Result<(), String> { + match args { + [target] if target == "knowledge-rust-local" => knowledge_rust_local(), + _ => Err("usage: cargo xtask smoke knowledge-rust-local".to_owned()), + } +} + +fn knowledge_rust_local() -> Result<(), String> { + let root = workspace_root()?; + let sdk_path = root.join("crates/sdk"); + let tempdir = + tempfile::tempdir().map_err(|error| format!("failed to create smoke tempdir: {error}"))?; + write_consumer(&tempdir.path().join("Cargo.toml"), &sdk_path)?; + let src_dir = tempdir.path().join("src"); + fs::create_dir_all(&src_dir) + .map_err(|error| format!("failed to create {}: {error}", src_dir.display()))?; + fs::write(src_dir.join("main.rs"), CONSUMER_MAIN) + .map_err(|error| format!("failed to write smoke consumer main.rs: {error}"))?; + let status = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(tempdir.path()) + .status() + .map_err(|error| format!("failed to run smoke cargo check: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "knowledge Rust local smoke failed with status {status}" + )) + } +} + +fn write_consumer(path: &Path, sdk_path: &Path) -> Result<(), String> { + let sdk_path = serde_json::to_string(&sdk_path.to_string_lossy()) + .map_err(|error| format!("failed to render SDK path: {error}"))?; + let cargo_toml = format!( + r#"[package] +name = "radroots-sdk-knowledge-smoke" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +radroots_sdk = {{ path = {sdk_path}, features = ["std", "serde", "serde_json", "nostr", "knowledge"] }} +nostr = "0.44.2" +"# + ); + fs::write(path, cargo_toml) + .map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +const CONSUMER_MAIN: &str = r#"use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use radroots_sdk::knowledge::prelude::*; + +const SECRET_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; +const CREATED_AT: u32 = 1_800_000_000; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let claim = RadrootsKnowledgeClaim { + schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_owned(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + claim_type: "practice_effect".to_owned(), + text: "Cover crops improve soil structure.".to_owned(), + 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_owned()], + quote_hash: Some(hex_64('5')), + chunk_id: Some("chunk-1".to_owned()), + }], + topics: vec!["cover-crops".to_owned()], + applies_to: vec!["local-food".to_owned()], + author_asserted_confidence: Some("medium".to_owned()), + supersedes: Vec::new(), + }; + let parts = KnowledgeEventBuilder::new().knowledge_claim(&claim)?; + let draft = KnowledgeDraftBuilder::new(public_key_hex(), CREATED_AT).knowledge_claim(&claim)?; + let decoded = verify_and_decode_radroots_event(sign_parts(parts)?)?; + let manifest = contract_manifest(); + + assert_eq!(draft.contract_id, KNOWLEDGE_CLAIM_CONTRACT_ID); + assert_eq!(manifest.contract_count, 11); + match decoded { + RadrootsDecodedEvent::KnowledgeClaim(parsed) => assert_eq!(parsed.data.data.text, claim.text), + _ => return Err("expected knowledge claim".into()), + } + Ok(()) +} + +fn sign_parts(parts: WireEventParts) -> Result<RadrootsNostrEvent, Box<dyn std::error::Error>> { + let tags = parts + .tags + .into_iter() + .map(Tag::parse) + .collect::<Result<Vec<_>, _>>()?; + let keys = Keys::parse(SECRET_KEY_HEX)?; + let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(u64::from(CREATED_AT))) + .sign_with_keys(&keys)?; + Ok(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 public_key_hex() -> String { + Keys::parse(SECRET_KEY_HEX) + .expect("keys") + .public_key() + .to_hex() +} + +fn hex_64(character: char) -> String { + character.to_string().repeat(64) +} + +fn event_ref(character: char, kind: u32) -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: hex_64(character), + author: hex_64('a'), + kind, + d_tag: None, + relays: Some(vec!["wss://relay.radroots.example".to_owned()]), + } +} +"#;