tangle


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

commit afd856acc35b9c23ec018a44c64bb231d085bf01
parent a59ea754318e21a91deaa14cbad63fcb11119282
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 20:13:37 -0700

protocol: add nostr scalar types

Diffstat:
MCargo.lock | 4++++
MCargo.toml | 2+-
Acrates/tangle_protocol/Cargo.toml | 11+++++++++++
Acrates/tangle_protocol/src/lib.rs | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 362 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5,3 +5,7 @@ version = 4 [[package]] name = "tangle" version = "0.1.0" + +[[package]] +name = "tangle_protocol" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/tangle"] +members = ["crates/tangle", "crates/tangle_protocol"] resolver = "2" [workspace.package] diff --git a/crates/tangle_protocol/Cargo.toml b/crates/tangle_protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tangle_protocol" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Nostr protocol types for tangle" + +[lints] +workspace = true diff --git a/crates/tangle_protocol/src/lib.rs b/crates/tangle_protocol/src/lib.rs @@ -0,0 +1,346 @@ +#![forbid(unsafe_code)] + +use core::fmt; +use core::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EventId(String); + +impl EventId { + pub const HEX_LENGTH: usize = 64; + + pub fn new(value: &str) -> Result<Self, String> { + require_lowercase_hex("event id", value, Self::HEX_LENGTH)?; + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for EventId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for EventId { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::new(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PublicKeyHex(String); + +impl PublicKeyHex { + pub const HEX_LENGTH: usize = 64; + + pub fn new(value: &str) -> Result<Self, String> { + require_lowercase_hex("public key", value, Self::HEX_LENGTH)?; + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PublicKeyHex { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for PublicKeyHex { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::new(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SignatureHex(String); + +impl SignatureHex { + pub const HEX_LENGTH: usize = 128; + + pub fn new(value: &str) -> Result<Self, String> { + require_lowercase_hex("signature", value, Self::HEX_LENGTH)?; + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SignatureHex { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SignatureHex { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::new(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubscriptionId(String); + +impl SubscriptionId { + pub const MAX_LENGTH: usize = 64; + + pub fn new(value: &str) -> Result<Self, String> { + let actual = value.chars().count(); + if actual == 0 { + return Err(empty_error("subscription id")); + } + if actual > Self::MAX_LENGTH { + return Err(too_long_error("subscription id", Self::MAX_LENGTH, actual)); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SubscriptionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SubscriptionId { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::new(value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UnixTimestamp(u64); + +impl UnixTimestamp { + pub fn new(value: u64) -> Self { + Self(value) + } + + pub fn as_u64(self) -> u64 { + self.0 + } +} + +impl fmt::Display for UnixTimestamp { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.0) + } +} + +impl From<u64> for UnixTimestamp { + fn from(value: u64) -> Self { + Self::new(value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Kind(u32); + +impl Kind { + pub fn new(value: u64) -> Result<Self, String> { + let value = u32::try_from(value).map_err(|_| kind_out_of_range_error(value))?; + Ok(Self(value)) + } + + pub fn as_u32(self) -> u32 { + self.0 + } +} + +impl fmt::Display for Kind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.0) + } +} + +impl TryFrom<u64> for Kind { + type Error = String; + + fn try_from(value: u64) -> Result<Self, Self::Error> { + Self::new(value) + } +} + +fn require_lowercase_hex(scalar: &'static str, value: &str, expected: usize) -> Result<(), String> { + let actual = value.chars().count(); + if actual != expected { + return Err(invalid_length_error(scalar, expected, actual)); + } + if value + .bytes() + .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase()) + { + return Err(non_lowercase_hex_error(scalar)); + } + Ok(()) +} + +fn empty_error(scalar: &'static str) -> String { + format!("{scalar} must not be empty") +} + +fn invalid_length_error(scalar: &'static str, expected: usize, actual: usize) -> String { + format!("{scalar} must be {expected} characters, got {actual}") +} + +fn too_long_error(scalar: &'static str, max: usize, actual: usize) -> String { + format!("{scalar} must be at most {max} characters, got {actual}") +} + +fn non_lowercase_hex_error(scalar: &'static str) -> String { + format!("{scalar} must be lowercase hex") +} + +fn kind_out_of_range_error(value: u64) -> String { + format!("kind must fit in u32, got {value}") +} + +#[cfg(test)] +mod tests { + use super::{ + EventId, Kind, PublicKeyHex, SignatureHex, SubscriptionId, UnixTimestamp, 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; + use std::hash::{Hash, Hasher}; + + #[test] + fn event_id_accepts_lowercase_hex() { + let value = "0".repeat(EventId::HEX_LENGTH); + let event_id = EventId::new(&value).expect("event id"); + + assert_eq!(event_id.as_str(), value); + assert_eq!(event_id.to_string(), value); + let cloned = event_id.clone(); + let mut hasher = DefaultHasher::new(); + cloned.hash(&mut hasher); + assert_ne!(hasher.finish(), 0); + assert_eq!(format!("{cloned:?}"), format!("EventId(\"{value}\")")); + assert!(cloned <= event_id); + assert_eq!(EventId::from_str(&value), Ok(event_id)); + } + + #[test] + fn fixed_hex_scalars_reject_bad_lengths_and_characters() { + assert_eq!( + EventId::new(&"0".repeat(EventId::HEX_LENGTH - 1)), + Err(format!( + "event id must be {} characters, got {}", + EventId::HEX_LENGTH, + EventId::HEX_LENGTH - 1 + )) + ); + assert_eq!( + EventId::new("bad"), + Err("event id must be 64 characters, got 3".to_owned()) + ); + let invalid_public_key = format!("{}G", "0".repeat(PublicKeyHex::HEX_LENGTH - 1)); + assert_eq!( + PublicKeyHex::new(&invalid_public_key), + Err("public key must be lowercase hex".to_owned()) + ); + let invalid_signature = format!("{}A", "0".repeat(SignatureHex::HEX_LENGTH - 1)); + assert_eq!( + SignatureHex::new(&invalid_signature), + Err("signature must be lowercase hex".to_owned()) + ); + } + + #[test] + fn public_key_and_signature_display_values() { + let public_key_value = "1".repeat(PublicKeyHex::HEX_LENGTH); + let signature_value = "2".repeat(SignatureHex::HEX_LENGTH); + let public_key = PublicKeyHex::new(&public_key_value).expect("pubkey"); + let signature = SignatureHex::new(&signature_value).expect("sig"); + + assert_eq!(public_key.as_str(), "1".repeat(PublicKeyHex::HEX_LENGTH)); + assert_eq!(signature.as_str(), "2".repeat(SignatureHex::HEX_LENGTH)); + assert_eq!(public_key.to_string(), public_key.as_str()); + assert_eq!(signature.to_string(), signature.as_str()); + assert_eq!(PublicKeyHex::from_str(public_key.as_str()), Ok(public_key)); + assert_eq!(SignatureHex::from_str(signature.as_str()), Ok(signature)); + } + + #[test] + fn subscription_id_rejects_empty_and_overlong_values() { + assert_eq!( + SubscriptionId::new(""), + Err("subscription id must not be empty".to_owned()) + ); + assert_eq!( + SubscriptionId::new(&"x".repeat(SubscriptionId::MAX_LENGTH + 1)), + Err(format!( + "subscription id must be at most {} characters, got {}", + SubscriptionId::MAX_LENGTH, + SubscriptionId::MAX_LENGTH + 1 + )) + ); + let subscription = SubscriptionId::new("radroots").expect("subscription"); + assert_eq!(subscription.as_str(), "radroots"); + assert_eq!(subscription.to_string(), "radroots"); + assert_eq!(SubscriptionId::from_str("radroots"), Ok(subscription)); + } + + #[test] + fn timestamp_and_kind_expose_numeric_values() { + let timestamp = UnixTimestamp::new(1_714_124_433); + let kind = Kind::new(30_402).expect("kind"); + + assert_eq!(timestamp.as_u64(), 1_714_124_433); + assert_eq!(timestamp.to_string(), "1714124433"); + assert_eq!(UnixTimestamp::from(7).as_u64(), 7); + assert_eq!(kind.as_u32(), 30_402); + assert_eq!(kind.to_string(), "30402"); + assert_eq!(Kind::try_from(30_402), Ok(kind)); + } + + #[test] + fn kind_rejects_values_outside_u32() { + let value = u64::from(u32::MAX) + 1; + + assert_eq!( + Kind::new(value), + Err(format!("kind must fit in u32, got {value}")) + ); + assert_eq!( + kind_out_of_range_error(value), + format!("kind must fit in u32, got {value}") + ); + } + + #[test] + fn scalar_errors_have_stable_messages() { + assert_eq!(empty_error("id"), "id must not be empty"); + assert_eq!( + invalid_length_error("id", 64, 63), + "id must be 64 characters, got 63" + ); + assert_eq!( + too_long_error("id", 64, 65), + "id must be at most 64 characters, got 65" + ); + assert_eq!(non_lowercase_hex_error("id"), "id must be lowercase hex"); + } +}