tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 982007380b04c86039d495a4d52c626b7e0ba67d
parent 81d6bd5f47f772095c1c09464c460f76f96927ba
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 20:18:24 -0700

protocol: add canonical event serialization

Diffstat:
MCargo.lock | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_protocol/Cargo.toml | 3+++
Mcrates/tangle_protocol/src/lib.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/tangle_protocol/tests/fixtures/canonical_empty_event.json | 1+
Acrates/tangle_protocol/tests/fixtures/canonical_escaped_event.json | 1+
Acrates/tangle_protocol/tests/fixtures/canonical_repeated_tags_event.json | 1+
Acrates/tangle_protocol/tests/fixtures/canonical_unicode_event.json | 1+
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é"]