commit 982007380b04c86039d495a4d52c626b7e0ba67d
parent 81d6bd5f47f772095c1c09464c460f76f96927ba
Author: triesap <tyson@radroots.org>
Date: Fri, 5 Jun 2026 20:18:24 -0700
protocol: add canonical event serialization
Diffstat:
7 files changed, 202 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,9 +3,107 @@
version = 4
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
name = "tangle"
version = "0.1.0"
[[package]]
name = "tangle_protocol"
version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/crates/tangle_protocol/Cargo.toml b/crates/tangle_protocol/Cargo.toml
@@ -7,5 +7,8 @@ rust-version.workspace = true
license.workspace = true
description = "Nostr protocol types for tangle"
+[dependencies]
+serde_json = "1"
+
[lints]
workspace = true
diff --git a/crates/tangle_protocol/src/lib.rs b/crates/tangle_protocol/src/lib.rs
@@ -419,6 +419,36 @@ impl fmt::Debug for EventShapeError {
impl std::error::Error for EventShapeError {}
+pub fn canonical_event_json(event: &UnsignedEvent) -> String {
+ let tags: Vec<serde_json::Value> = event
+ .tags()
+ .iter()
+ .map(|tag| {
+ serde_json::Value::Array(
+ tag.values()
+ .iter()
+ .map(|value| serde_json::Value::String(value.clone()))
+ .collect(),
+ )
+ })
+ .collect();
+ serde_json::json!([
+ 0,
+ event.pubkey().as_str(),
+ event.created_at().as_u64(),
+ event.kind().as_u32(),
+ tags,
+ event.content()
+ ])
+ .to_string()
+}
+
+impl UnsignedEvent {
+ pub fn canonical_json(&self) -> String {
+ canonical_event_json(self)
+ }
+}
+
fn require_lowercase_hex(scalar: &'static str, value: &str, expected: usize) -> Result<(), String> {
let actual = value.chars().count();
if actual != expected {
@@ -457,8 +487,9 @@ fn kind_out_of_range_error(value: u64) -> String {
mod tests {
use super::{
Event, EventId, EventShapeError, Kind, PublicKeyHex, RawEventJson, SignatureHex,
- SubscriptionId, Tag, TagName, TagValue, UnixTimestamp, UnsignedEvent, empty_error,
- invalid_length_error, kind_out_of_range_error, non_lowercase_hex_error, too_long_error,
+ SubscriptionId, Tag, TagName, TagValue, UnixTimestamp, UnsignedEvent, canonical_event_json,
+ empty_error, invalid_length_error, kind_out_of_range_error, non_lowercase_hex_error,
+ too_long_error,
};
use core::str::FromStr;
use std::collections::hash_map::DefaultHasher;
@@ -704,4 +735,68 @@ mod tests {
"EventShapeError { message: \"event field `pubkey` is missing\" }"
);
}
+
+ #[test]
+ fn canonical_event_json_serializes_empty_content_and_tags() {
+ let event = unsigned_event(Vec::new(), "");
+
+ assert_eq!(
+ event.canonical_json(),
+ include_str!("../tests/fixtures/canonical_empty_event.json").trim_end()
+ );
+ assert_eq!(canonical_event_json(&event), event.canonical_json());
+ }
+
+ #[test]
+ fn canonical_event_json_serializes_escaped_content() {
+ let event = unsigned_event(
+ vec![Tag::from_parts("alt", &["quote"]).expect("tag")],
+ "quote \" slash \\ newline\n",
+ );
+
+ assert_eq!(
+ event.canonical_json(),
+ include_str!("../tests/fixtures/canonical_escaped_event.json").trim_end()
+ );
+ }
+
+ #[test]
+ fn canonical_event_json_serializes_unicode_content() {
+ let event = unsigned_event(
+ vec![Tag::from_parts("t", &["radroots"]).expect("tag")],
+ "radroots 🌱 café",
+ );
+
+ assert_eq!(
+ event.canonical_json(),
+ include_str!("../tests/fixtures/canonical_unicode_event.json").trim_end()
+ );
+ }
+
+ #[test]
+ fn canonical_event_json_preserves_repeated_tags() {
+ let event = unsigned_event(
+ vec![
+ Tag::from_parts("e", &["one"]).expect("first e"),
+ Tag::from_parts("e", &["two"]).expect("second e"),
+ Tag::from_parts("p", &["peer", "wss://relay.example"]).expect("p"),
+ ],
+ "with repeated tags",
+ );
+
+ assert_eq!(
+ event.canonical_json(),
+ include_str!("../tests/fixtures/canonical_repeated_tags_event.json").trim_end()
+ );
+ }
+
+ fn unsigned_event(tags: Vec<Tag>, content: &str) -> UnsignedEvent {
+ UnsignedEvent::new(
+ PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
+ UnixTimestamp::new(1_714_124_433),
+ Kind::new(1).expect("kind"),
+ tags,
+ content,
+ )
+ }
}
diff --git a/crates/tangle_protocol/tests/fixtures/canonical_empty_event.json b/crates/tangle_protocol/tests/fixtures/canonical_empty_event.json
@@ -0,0 +1 @@
+[0,"1111111111111111111111111111111111111111111111111111111111111111",1714124433,1,[],""]
diff --git a/crates/tangle_protocol/tests/fixtures/canonical_escaped_event.json b/crates/tangle_protocol/tests/fixtures/canonical_escaped_event.json
@@ -0,0 +1 @@
+[0,"1111111111111111111111111111111111111111111111111111111111111111",1714124433,1,[["alt","quote"]],"quote \" slash \\ newline\n"]
diff --git a/crates/tangle_protocol/tests/fixtures/canonical_repeated_tags_event.json b/crates/tangle_protocol/tests/fixtures/canonical_repeated_tags_event.json
@@ -0,0 +1 @@
+[0,"1111111111111111111111111111111111111111111111111111111111111111",1714124433,1,[["e","one"],["e","two"],["p","peer","wss://relay.example"]],"with repeated tags"]
diff --git a/crates/tangle_protocol/tests/fixtures/canonical_unicode_event.json b/crates/tangle_protocol/tests/fixtures/canonical_unicode_event.json
@@ -0,0 +1 @@
+[0,"1111111111111111111111111111111111111111111111111111111111111111",1714124433,1,[["t","radroots"]],"radroots 🌱 café"]