commit 5f136f7a29d00ab53676d7f3cd7b9f144a97326d
parent ea16d393a4fbeca4c1b1cedcebe991e1e50244ec
Author: triesap <tyson@radroots.org>
Date: Mon, 25 May 2026 04:53:27 +0000
local_events: add relay set fingerprint
Diffstat:
3 files changed, 87 insertions(+), 0 deletions(-)
diff --git a/crates/local_events/src/lib.rs b/crates/local_events/src/lib.rs
@@ -4,6 +4,7 @@ mod error;
mod migrations;
mod models;
mod order_work;
+mod relay_set;
mod store;
pub use error::LocalEventsError;
@@ -21,4 +22,5 @@ pub use order_work::{
validate_supported_buyer_order_request_local_work_payload,
validate_unsupported_buyer_order_request_local_work_payload,
};
+pub use relay_set::{CANONICAL_RELAY_SET_FINGERPRINT_VERSION, canonical_relay_set_fingerprint};
pub use store::LocalEventsStore;
diff --git a/crates/local_events/src/relay_set.rs b/crates/local_events/src/relay_set.rs
@@ -0,0 +1,49 @@
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeSet;
+
+const FNV_1A_64_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
+const FNV_1A_64_PRIME: u64 = 0x100000001b3;
+
+pub const CANONICAL_RELAY_SET_FINGERPRINT_VERSION: &str = "radroots-local-events-relay-set-v1";
+
+/// Returns the canonical shared local-event relay-set fingerprint.
+///
+/// Relay URLs are trimmed, blank entries are discarded, duplicates are removed,
+/// and the remaining set is sorted before hashing.
+pub fn canonical_relay_set_fingerprint<I, S>(relay_urls: I) -> Option<String>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ let relays = relay_urls
+ .into_iter()
+ .filter_map(|relay_url| {
+ let relay_url = relay_url.as_ref().trim();
+ (!relay_url.is_empty()).then(|| relay_url.to_owned())
+ })
+ .collect::<BTreeSet<_>>();
+
+ if relays.is_empty() {
+ return None;
+ }
+
+ let mut hash = FNV_1A_64_OFFSET_BASIS;
+ for relay in relays {
+ update_hash(&mut hash, relay.len().to_string().as_bytes());
+ update_hash(&mut hash, &[0]);
+ update_hash(&mut hash, relay.as_bytes());
+ update_hash(&mut hash, &[0]);
+ }
+
+ Some(format!(
+ "{CANONICAL_RELAY_SET_FINGERPRINT_VERSION}:{hash:016x}"
+ ))
+}
+
+fn update_hash(hash: &mut u64, bytes: &[u8]) {
+ for byte in bytes {
+ *hash ^= u64::from(*byte);
+ *hash = hash.wrapping_mul(FNV_1A_64_PRIME);
+ }
+}
diff --git a/crates/local_events/tests/relay_set.rs b/crates/local_events/tests/relay_set.rs
@@ -0,0 +1,36 @@
+use radroots_local_events::{
+ CANONICAL_RELAY_SET_FINGERPRINT_VERSION, canonical_relay_set_fingerprint,
+};
+
+#[test]
+fn relay_set_fingerprint_trims_sorts_and_dedupes() {
+ let first = canonical_relay_set_fingerprint([
+ " wss://relay-b.example ",
+ "wss://relay-a.example",
+ "wss://relay-b.example",
+ ])
+ .expect("fingerprint");
+ let second =
+ canonical_relay_set_fingerprint(["wss://relay-a.example", "wss://relay-b.example"])
+ .expect("fingerprint");
+
+ assert_eq!(first, second);
+ assert!(first.starts_with(CANONICAL_RELAY_SET_FINGERPRINT_VERSION));
+}
+
+#[test]
+fn relay_set_fingerprint_rejects_empty_entries() {
+ let fingerprint = canonical_relay_set_fingerprint([" ", "", "\t"]);
+
+ assert_eq!(fingerprint, None);
+}
+
+#[test]
+fn relay_set_fingerprint_changes_when_relay_set_changes() {
+ let first = canonical_relay_set_fingerprint(["wss://relay-a.example"]).expect("fingerprint");
+ let second =
+ canonical_relay_set_fingerprint(["wss://relay-a.example", "wss://relay-b.example"])
+ .expect("fingerprint");
+
+ assert_ne!(first, second);
+}