lib

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

commit f2fa280846d814595e02ad924d10ed39f7460a8e
parent 773f20df3dfe57b6aecf8963561cd108f51a00c6
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 22:54:50 +0000

feat(event_codec): add authored event plans

- add immutable codec-owned authored bodies and event plans
- bind plans to exact authors timestamps and expected event identifiers
- commit semantic fields with a domain-separated stable digest
- prove private construction and no-std feature compatibility


Diffstat:
Mcrates/event_codec/Cargo.toml | 8+++-----
Acrates/event_codec/src/authoring/mod.rs | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/lib.rs | 1+
Mcrates/event_codec/tests/package_boundary.rs | 5+++--
4 files changed, 296 insertions(+), 7 deletions(-)

diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml @@ -39,7 +39,7 @@ serde = [ ] json = ["serde", "dep:serde_json"] knowledge = ["json", "radroots_event/knowledge"] -manifests = ["knowledge", "dep:hex", "dep:sha2"] +manifests = ["knowledge"] [dependencies] radroots_blossom = { workspace = true, default-features = false } @@ -54,10 +54,8 @@ serde_json = { workspace = true, default-features = false, features = [ "alloc", ], optional = true } secp256k1 = { workspace = true } -hex = { version = "0.4", default-features = false, features = [ - "alloc", -], optional = true } -sha2 = { workspace = true, default-features = false, optional = true } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +sha2 = { workspace = true, default-features = false } [dev-dependencies] nostr = { workspace = true, features = ["std"] } diff --git a/crates/event_codec/src/authoring/mod.rs b/crates/event_codec/src/authoring/mod.rs @@ -0,0 +1,289 @@ +//! Exact authored event bodies, plans, and semantic digests. + +#[cfg(not(feature = "std"))] +use alloc::{borrow::ToOwned, string::String, vec::Vec}; +#[cfg(feature = "std")] +use std::{borrow::ToOwned, string::String, vec::Vec}; + +use radroots_event::{GenericEventDraft, contract::ContractKey, draft::DraftError, id::EventId}; +use radroots_identity::PublicKey; +use sha2::{Digest, Sha256}; + +pub const PLAN_WIRE_VERSION_V1: u32 = 1; +const PLAN_DIGEST_DOMAIN: &[u8] = b"radroots.authored_event_plan.v1"; + +/// Exact contract-owned NIP-01 payload fields before author/time binding. +/// +/// Fields are private and there is deliberately no arbitrary-parts +/// constructor. Bodies enter through generic validation, strict typed +/// conversion, or bounded historical reconstruction. +/// +/// ```compile_fail +/// use radroots_event_codec::authoring::AuthoredEventBody; +/// +/// let _ = AuthoredEventBody::new("radroots.social.geochat.v1", 20_000, vec![], "raw"); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthoredEventBody { + contract: ContractKey, + kind: u32, + tags: Vec<Vec<String>>, + content: String, +} + +impl AuthoredEventBody { + fn from_generic(draft: &GenericEventDraft) -> Self { + Self { + contract: draft.contract().clone(), + kind: draft.kind_u32(), + tags: draft.tags_as_vec(), + content: draft.content().to_owned(), + } + } + + #[must_use] + pub const fn contract(&self) -> &ContractKey { + &self.contract + } + + #[must_use] + pub const fn kind(&self) -> u32 { + self.kind + } + + #[must_use] + pub fn tags(&self) -> &[Vec<String>] { + &self.tags + } + + #[must_use] + pub fn content(&self) -> &str { + &self.content + } +} + +/// Stable SHA-256 commitment to an exact authored event plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PlanDigest([u8; Self::BYTE_LENGTH]); + +impl PlanDigest { + pub const BYTE_LENGTH: usize = 32; + + #[must_use] + pub const fn from_bytes(bytes: [u8; Self::BYTE_LENGTH]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; Self::BYTE_LENGTH] { + &self.0 + } + + #[must_use] + pub fn to_hex(self) -> String { + hex::encode(self.0) + } +} + +/// One immutable exact NIP-01 event plan, ready for authorization and signing. +/// +/// ```compile_fail +/// use radroots_event_codec::authoring::AuthoredEventPlan; +/// +/// let _ = AuthoredEventPlan::new(); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthoredEventPlan { + body: AuthoredEventBody, + author: PublicKey, + created_at: u64, + expected_event_id: EventId, + digest: PlanDigest, +} + +impl AuthoredEventPlan { + /// Converts the generic-only event input into its immutable authored plan. + pub fn from_generic(draft: GenericEventDraft) -> Result<Self, DraftError> { + draft.validate_for_authoring()?; + let author = *draft.expected_pubkey(); + let created_at = draft.created_at_u64(); + let expected_event_id = *draft.expected_event_id(); + let body = AuthoredEventBody::from_generic(&draft); + Ok(Self::from_validated_parts( + body, + author, + created_at, + expected_event_id, + )) + } + + fn from_validated_parts( + body: AuthoredEventBody, + author: PublicKey, + created_at: u64, + expected_event_id: EventId, + ) -> Self { + let digest = compute_plan_digest(&body, &author, created_at, &expected_event_id); + Self { + body, + author, + created_at, + expected_event_id, + digest, + } + } + + #[must_use] + pub const fn body(&self) -> &AuthoredEventBody { + &self.body + } + + #[must_use] + pub const fn author(&self) -> &PublicKey { + &self.author + } + + #[must_use] + pub const fn created_at(&self) -> u64 { + self.created_at + } + + #[must_use] + pub const fn expected_event_id(&self) -> &EventId { + &self.expected_event_id + } + + #[must_use] + pub const fn digest(&self) -> PlanDigest { + self.digest + } +} + +fn compute_plan_digest( + body: &AuthoredEventBody, + author: &PublicKey, + created_at: u64, + expected_event_id: &EventId, +) -> PlanDigest { + let mut encoder = DigestEncoder::new(); + encoder.bytes(PLAN_DIGEST_DOMAIN); + encoder.u32(PLAN_WIRE_VERSION_V1); + encoder.u32(body.contract.registry_version().get()); + encoder.bytes(body.contract.contract_id().as_str().as_bytes()); + encoder.bytes(author.as_bytes()); + encoder.u64(created_at); + encoder.u32(body.kind); + encoder.u32(body.tags.len() as u32); + for tag in &body.tags { + encoder.u32(tag.len() as u32); + for element in tag { + encoder.bytes(element.as_bytes()); + } + } + encoder.bytes(body.content.as_bytes()); + encoder.bytes(expected_event_id.as_bytes()); + encoder.finish() +} + +struct DigestEncoder(Sha256); + +impl DigestEncoder { + fn new() -> Self { + Self(Sha256::new()) + } + + fn u32(&mut self, value: u32) { + self.0.update(value.to_be_bytes()); + } + + fn u64(&mut self, value: u64) { + self.0.update(value.to_be_bytes()); + } + + fn bytes(&mut self, value: &[u8]) { + self.u64(value.len() as u64); + self.0.update(value); + } + + fn finish(self) -> PlanDigest { + PlanDigest::from_bytes(self.0.finalize().into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_event::envelope::kind::KIND_GEOCHAT; + + const ALICE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + fn plan( + author: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> AuthoredEventPlan { + AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + KIND_GEOCHAT, + created_at, + tags, + content, + author, + ) + .expect("generic draft"), + ) + .expect("authored plan") + } + + #[test] + fn generic_input_becomes_an_exact_immutable_plan() { + let plan = plan(ALICE, 1_700_000_000, Vec::new(), "hello"); + assert_eq!( + plan.body().contract().contract_id().as_str(), + "radroots.social.geochat.v1" + ); + assert_eq!(plan.body().kind(), KIND_GEOCHAT); + assert!(plan.body().tags().is_empty()); + assert_eq!(plan.body().content(), "hello"); + assert_eq!(plan.author().to_hex(), ALICE); + assert_eq!(plan.created_at(), 1_700_000_000); + assert_eq!(plan.digest().to_hex().len(), 64); + } + + #[test] + fn semantic_digest_has_a_stable_golden_vector() { + let plan = plan( + ALICE, + 1_700_000_000, + vec![vec!["g".to_owned(), "u4pru".to_owned()]], + "hello 🍓", + ); + assert_eq!( + plan.digest().to_hex(), + "eb719fe792ef467b35810967c28a7fdc1c2fb8cebaefdf76efa878fe6dd4f1ec" + ); + } + + #[test] + fn every_bound_authoring_field_changes_the_plan_digest() { + let base = plan(ALICE, 1_700_000_000, Vec::new(), "hello"); + let variants = [ + plan(BOB, 1_700_000_000, Vec::new(), "hello"), + plan(ALICE, 1_700_000_001, Vec::new(), "hello"), + plan( + ALICE, + 1_700_000_000, + vec![vec!["g".to_owned(), "u4pru".to_owned()]], + "hello", + ), + plan(ALICE, 1_700_000_000, Vec::new(), "hello!"), + ]; + for variant in variants { + assert_ne!(variant.expected_event_id(), base.expected_event_id()); + assert_ne!(variant.digest(), base.digest()); + } + } +} diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -5,6 +5,7 @@ #[cfg(not(feature = "std"))] extern crate alloc; +pub mod authoring; pub mod canonical; mod codec; pub mod decode; diff --git a/crates/event_codec/tests/package_boundary.rs b/crates/event_codec/tests/package_boundary.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; #[allow(unused_imports)] -use radroots_event_codec::{canonical as _, decode as _, encode as _, verify as _}; +use radroots_event_codec::{authoring as _, canonical as _, decode as _, encode as _, verify as _}; const MANIFEST: &str = include_str!("../Cargo.toml"); const README: &str = include_str!("../README.md"); @@ -46,6 +46,7 @@ fn crate_root_declares_every_approved_module() { let declared = root_declarations("pub mod "); for module in [ "admission", + "authoring", "canonical", "decode", "encode", @@ -159,7 +160,7 @@ fn serialization_features_are_explicit_additive_and_final() { assert!(MANIFEST.contains("default = [\"std\", \"json\"]")); assert!(MANIFEST.contains("json = [\"serde\", \"dep:serde_json\"]")); assert!(MANIFEST.contains("knowledge = [\"json\", \"radroots_event/knowledge\"]")); - assert!(MANIFEST.contains("manifests = [\"knowledge\", \"dep:hex\", \"dep:sha2\"]")); + assert!(MANIFEST.contains("manifests = [\"knowledge\"]")); for forbidden in [ "serde_json",