lib

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

commit 210afb3014fddc7a8debebfce0d29302491595cc
parent 5196fbf56edc42a34c35699cdf78f44b84b1263c
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Dec 2025 17:43:43 +0000

nostr: add NIP-17 gift wrapping and new event codecs


- Add gift_wrap, seal, and message_file event types with encode/decode + tests
- Expose WASM tag builders for message_file, seal, and gift_wrap
- Introduce nip17 module for wrap/unwrap using nip59 gift_wrap and rumor parsing
- Bump nostr crates to 0.44.x and standardize rand/os-rng dependency wiring

Diffstat:
MCargo.lock | 72+++++++++++++++++++++++++++++-------------------------------------------
MCargo.toml | 6+++---
Mevents-codec-wasm/src/lib.rs | 39+++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/gift_wrap/decode.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/gift_wrap/encode.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Cevents-codec/src/message/mod.rs -> events-codec/src/gift_wrap/mod.rs | 0
Mevents-codec/src/lib.rs | 3+++
Mevents-codec/src/message/decode.rs | 73+++++--------------------------------------------------------------------
Mevents-codec/src/message/encode.rs | 63++++++---------------------------------------------------------
Mevents-codec/src/message/mod.rs | 1+
Aevents-codec/src/message/tags.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/message_file/decode.rs | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/message_file/encode.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Cevents-codec/src/message/mod.rs -> events-codec/src/message_file/mod.rs | 0
Aevents-codec/src/seal/decode.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/seal/encode.rs | 36++++++++++++++++++++++++++++++++++++
Cevents-codec/src/message/mod.rs -> events-codec/src/seal/mod.rs | 0
Mevents-codec/src/tag_builders.rs | 30++++++++++++++++++++++++++++++
Aevents-codec/tests/gift_wrap.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/message_file.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/seal.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents/bindings/ts/src/types.ts | 22++++++++++++++++++++++
Mevents/bindings/ts/src/typeshare-types.ts | 3+++
Aevents/src/gift_wrap.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents/src/kinds.rs | 6++++++
Mevents/src/lib.rs | 3+++
Aevents/src/message_file.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents/src/seal.rs | 37+++++++++++++++++++++++++++++++++++++
Mevents/src/typeshare_kinds.rs | 6++++++
Midentity/Cargo.toml | 2+-
Mnostr/Cargo.toml | 4++++
Mnostr/src/lib.rs | 13+++++++++++++
Anostr/src/nip17.rs | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnostr/src/util.rs | 2+-
34 files changed, 1695 insertions(+), 173 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -319,6 +319,12 @@ dependencies = [ ] [[package]] +name = "btreecap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" + +[[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -547,7 +553,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1156,9 +1161,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -1343,9 +1345,7 @@ dependencies = [ [[package]] name = "nostr" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62a97d745f1bd8d5e05a978632bbb87b0614567d5142906fe7c86fb2440faac6" +version = "0.44.1" dependencies = [ "aes", "base64 0.22.1", @@ -1355,8 +1355,10 @@ dependencies = [ "cbc", "chacha20", "chacha20poly1305", - "getrandom 0.2.16", + "hex", "instant", + "once_cell", + "rand", "scrypt", "secp256k1", "serde", @@ -1367,24 +1369,29 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c75a8c2175d2785ba73cfddef21d1e30da5fbbdf158569b6808ba44973a15b" +version = "0.44.0" dependencies = [ + "btreecap", "lru", "nostr", "tokio", ] [[package]] +name = "nostr-gossip" +version = "0.44.0" +dependencies = [ + "nostr", +] + +[[package]] name = "nostr-relay-pool" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "265d9b44771ed15db93b183a0c93dbb703b2b0d0b74dffb5c2a081be52373a5a" +version = "0.44.0" dependencies = [ "async-utility", "async-wsocket", "atomic-destructor", + "hex", "lru", "negentropy", "nostr", @@ -1395,15 +1402,15 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599f8963d6a1522a13b1a2b0ea6e168acfc367706606f1d33fa595e91fa22db0" +version = "0.44.1" dependencies = [ "async-utility", "nostr", "nostr-database", + "nostr-gossip", "nostr-relay-pool", "tokio", + "tracing", ] [[package]] @@ -1648,7 +1655,7 @@ dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", "rustls", @@ -1804,6 +1811,7 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", + "tokio", ] [[package]] @@ -1922,37 +1930,16 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.3", ] [[package]] name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" @@ -2242,7 +2229,6 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -2856,7 +2842,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand", "rustls", "rustls-pki-types", "sha1", @@ -2973,7 +2959,7 @@ checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", - "rand 0.9.2", + "rand", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml @@ -57,9 +57,9 @@ directories = { version = "6" } futures = { version = "0.3" } hex = { version = "0.4" } js-sys = { version = "0.3" } -nostr = { version = "0.43.0" } -nostr-relay-pool = { version = "0.43.0" } -nostr-sdk = { version = "0.43.0" } +nostr = { version = "0.44.1", path = "../refs/rust-nostr/crates/nostr" } +nostr-relay-pool = { version = "0.44.0", path = "../refs/rust-nostr/crates/nostr-relay-pool" } +nostr-sdk = { version = "0.44.1", path = "../refs/rust-nostr/crates/nostr-sdk" } num_cpus = { version = "1.17.0" } secrecy = { version = "0.10.3" } serde = { version = "1", default-features = false, features = ["derive"] } diff --git a/events-codec-wasm/src/lib.rs b/events-codec-wasm/src/lib.rs @@ -10,16 +10,22 @@ use radroots_events::listing::RadrootsListing; use radroots_events::list::RadrootsList; use radroots_events::list_set::RadrootsListSet; use radroots_events::message::RadrootsMessage; +use radroots_events::message_file::RadrootsMessageFile; use radroots_events::reaction::RadrootsReaction; +use radroots_events::gift_wrap::RadrootsGiftWrap; +use radroots_events::seal::RadrootsSeal; use radroots_events_codec::comment::encode::comment_build_tags; use radroots_events_codec::follow::encode::follow_build_tags; +use radroots_events_codec::gift_wrap::encode::gift_wrap_build_tags; use radroots_events_codec::job::feedback::encode::job_feedback_build_tags; use radroots_events_codec::job::request::encode::job_request_build_tags; use radroots_events_codec::job::result::encode::job_result_build_tags; use radroots_events_codec::list::encode::list_build_tags; use radroots_events_codec::list_set::encode::list_set_build_tags; use radroots_events_codec::message::encode::message_build_tags; +use radroots_events_codec::message_file::encode::message_file_build_tags; use radroots_events_codec::reaction::encode::reaction_build_tags; +use radroots_events_codec::seal::encode::seal_build_tags; use radroots_events_codec::listing::tags::{ listing_tags as listing_tags_impl, listing_tags_full as listing_tags_full_impl, @@ -62,6 +68,18 @@ fn parse_message(message_json: &str) -> Result<RadrootsMessage, JsValue> { serde_json::from_str(message_json).map_err(err_js) } +fn parse_message_file(message_json: &str) -> Result<RadrootsMessageFile, JsValue> { + serde_json::from_str(message_json).map_err(err_js) +} + +fn parse_gift_wrap(gift_wrap_json: &str) -> Result<RadrootsGiftWrap, JsValue> { + serde_json::from_str(gift_wrap_json).map_err(err_js) +} + +fn parse_seal(seal_json: &str) -> Result<RadrootsSeal, JsValue> { + serde_json::from_str(seal_json).map_err(err_js) +} + fn parse_list(list_json: &str) -> Result<RadrootsList, JsValue> { serde_json::from_str(list_json).map_err(err_js) } @@ -150,3 +168,24 @@ pub fn message_tags(message_json: &str) -> Result<String, JsValue> { let tags = message_build_tags(&message).map_err(err_js)?; tags_to_json(tags) } + +#[wasm_bindgen(js_name = message_file_tags)] +pub fn message_file_tags(message_json: &str) -> Result<String, JsValue> { + let message = parse_message_file(message_json)?; + let tags = message_file_build_tags(&message).map_err(err_js)?; + tags_to_json(tags) +} + +#[wasm_bindgen(js_name = seal_tags)] +pub fn seal_tags(seal_json: &str) -> Result<String, JsValue> { + let seal = parse_seal(seal_json)?; + let tags = seal_build_tags(&seal).map_err(err_js)?; + tags_to_json(tags) +} + +#[wasm_bindgen(js_name = gift_wrap_tags)] +pub fn gift_wrap_tags(gift_wrap_json: &str) -> Result<String, JsValue> { + let gift_wrap = parse_gift_wrap(gift_wrap_json)?; + let tags = gift_wrap_build_tags(&gift_wrap).map_err(err_js)?; + tags_to_json(tags) +} diff --git a/events-codec/src/gift_wrap/decode.rs b/events-codec/src/gift_wrap/decode.rs @@ -0,0 +1,119 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapEventIndex, RadrootsGiftWrapEventMetadata, RadrootsGiftWrapRecipient}, + kinds::KIND_GIFT_WRAP, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = KIND_GIFT_WRAP; + +fn parse_recipient(tags: &[Vec<String>]) -> Result<RadrootsGiftWrapRecipient, EventParseError> { + let tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("p")) + .ok_or(EventParseError::MissingTag("p"))?; + let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; + if public_key.trim().is_empty() { + return Err(EventParseError::InvalidTag("p")); + } + let relay_url = match tag.get(2) { + Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("p")), + Some(value) => Some(value.clone()), + None => None, + }; + Ok(RadrootsGiftWrapRecipient { + public_key: public_key.clone(), + relay_url, + }) +} + +fn parse_expiration(tags: &[Vec<String>]) -> Result<Option<u32>, EventParseError> { + let value = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("expiration")) + .and_then(|t| t.get(1)); + let Some(value) = value else { return Ok(None); }; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("expiration")); + } + let expiration = value + .parse::<u32>() + .map_err(|e| EventParseError::InvalidNumber("expiration", e))?; + Ok(Some(expiration)) +} + +pub fn gift_wrap_from_tags( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGiftWrap, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "1059", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + let recipient = parse_recipient(tags)?; + let expiration = parse_expiration(tags)?; + Ok(RadrootsGiftWrap { + recipient, + content: content.to_string(), + expiration, + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsGiftWrapEventMetadata, EventParseError> { + let gift_wrap = gift_wrap_from_tags(kind, &tags, &content)?; + Ok(RadrootsGiftWrapEventMetadata { + id, + author, + published_at, + kind, + gift_wrap, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsGiftWrapEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsGiftWrapEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/gift_wrap/encode.rs b/events-codec/src/gift_wrap/encode.rs @@ -0,0 +1,63 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; +#[cfg(not(feature = "std"))] +use alloc::vec; + +use radroots_events::gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapRecipient}; +use radroots_events::kinds::KIND_GIFT_WRAP; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = KIND_GIFT_WRAP; + +fn validate_recipient( + recipient: &RadrootsGiftWrapRecipient, +) -> Result<Vec<String>, EventEncodeError> { + if recipient.public_key.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipient.public_key")); + } + let mut tag = Vec::with_capacity(3); + tag.push("p".to_string()); + tag.push(recipient.public_key.clone()); + if let Some(relay_url) = &recipient.relay_url { + if relay_url.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipient.relay_url")); + } + tag.push(relay_url.clone()); + } + Ok(tag) +} + +pub fn gift_wrap_build_tags( + gift_wrap: &RadrootsGiftWrap, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = Vec::with_capacity(2); + tags.push(validate_recipient(&gift_wrap.recipient)?); + if let Some(expiration) = gift_wrap.expiration { + tags.push(vec!["expiration".to_string(), expiration.to_string()]); + } + Ok(tags) +} + +pub fn to_wire_parts(gift_wrap: &RadrootsGiftWrap) -> Result<WireEventParts, EventEncodeError> { + if gift_wrap.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + let tags = gift_wrap_build_tags(gift_wrap)?; + Ok(WireEventParts { + kind: DEFAULT_KIND, + content: gift_wrap.content.clone(), + tags, + }) +} + +pub fn to_wire_parts_with_kind( + gift_wrap: &RadrootsGiftWrap, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != DEFAULT_KIND { + return Err(EventEncodeError::InvalidKind(kind)); + } + to_wire_parts(gift_wrap) +} diff --git a/events-codec/src/message/mod.rs b/events-codec/src/gift_wrap/mod.rs diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs @@ -13,9 +13,12 @@ pub mod wire; pub mod comment; pub mod follow; pub mod app_data; +pub mod gift_wrap; pub mod message; +pub mod message_file; pub mod post; pub mod reaction; +pub mod seal; pub mod listing; pub mod list; diff --git a/events-codec/src/message/decode.rs b/events-codec/src/message/decode.rs @@ -2,67 +2,18 @@ use alloc::{string::{String, ToString}, vec::Vec}; use radroots_events::{ - RadrootsNostrEvent, RadrootsNostrEventPtr, + RadrootsNostrEvent, message::{ RadrootsMessage, RadrootsMessageEventIndex, RadrootsMessageEventMetadata, - RadrootsMessageRecipient, }, kinds::KIND_MESSAGE, }; use crate::error::EventParseError; +use crate::message::tags::{parse_recipients, parse_reply_tag, parse_subject_tag}; const DEFAULT_KIND: u32 = KIND_MESSAGE; -fn parse_recipient_tag(tag: &[String]) -> Result<RadrootsMessageRecipient, EventParseError> { - if tag.get(0).map(|s| s.as_str()) != Some("p") { - return Err(EventParseError::InvalidTag("p")); - } - let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; - if public_key.trim().is_empty() { - return Err(EventParseError::InvalidTag("p")); - } - let relay_url = match tag.get(2) { - Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("p")), - Some(value) => Some(value.clone()), - None => None, - }; - Ok(RadrootsMessageRecipient { - public_key: public_key.clone(), - relay_url, - }) -} - -fn parse_reply_tag(tag: &[String]) -> Result<RadrootsNostrEventPtr, EventParseError> { - if tag.get(0).map(|s| s.as_str()) != Some("e") { - return Err(EventParseError::InvalidTag("e")); - } - let id = tag.get(1).ok_or(EventParseError::InvalidTag("e"))?; - if id.trim().is_empty() { - return Err(EventParseError::InvalidTag("e")); - } - let relay = match tag.get(2) { - Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("e")), - Some(value) => Some(value.clone()), - None => None, - }; - Ok(RadrootsNostrEventPtr { - id: id.clone(), - relays: relay, - }) -} - -fn parse_subject_tag(tag: &[String]) -> Result<String, EventParseError> { - if tag.get(0).map(|s| s.as_str()) != Some("subject") { - return Err(EventParseError::InvalidTag("subject")); - } - let subject = tag.get(1).ok_or(EventParseError::InvalidTag("subject"))?; - if subject.trim().is_empty() { - return Err(EventParseError::InvalidTag("subject")); - } - Ok(subject.clone()) -} - pub fn message_from_tags( kind: u32, tags: &[Vec<String>], @@ -78,25 +29,11 @@ pub fn message_from_tags( return Err(EventParseError::InvalidTag("content")); } - let mut recipients = Vec::new(); - for tag in tags.iter().filter(|t| t.get(0).map(|s| s.as_str()) == Some("p")) { - recipients.push(parse_recipient_tag(tag)?); - } - if recipients.is_empty() { - return Err(EventParseError::MissingTag("p")); - } + let recipients = parse_recipients(tags)?; - let reply_to = tags - .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some("e")) - .map(|tag| parse_reply_tag(tag)) - .transpose()?; + let reply_to = parse_reply_tag(tags)?; - let subject = tags - .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some("subject")) - .map(|tag| parse_subject_tag(tag)) - .transpose()?; + let subject = parse_subject_tag(tags)?; Ok(RadrootsMessage { recipients, diff --git a/events-codec/src/message/encode.rs b/events-codec/src/message/encode.rs @@ -1,74 +1,23 @@ #[cfg(not(feature = "std"))] -use alloc::{string::{String, ToString}, vec::Vec}; +use alloc::{string::String, vec::Vec}; -use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient}; +use radroots_events::message::RadrootsMessage; use radroots_events::kinds::KIND_MESSAGE; use crate::error::EventEncodeError; +use crate::message::tags::{build_recipient_tags, build_reply_tag, build_subject_tag}; use crate::wire::WireEventParts; const DEFAULT_KIND: u32 = KIND_MESSAGE; -fn validate_recipient(recipient: &RadrootsMessageRecipient) -> Result<(), EventEncodeError> { - if recipient.public_key.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("recipients.public_key")); - } - if let Some(relay_url) = &recipient.relay_url { - if relay_url.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("recipients.relay_url")); - } - } - Ok(()) -} - pub fn message_build_tags(message: &RadrootsMessage) -> Result<Vec<Vec<String>>, EventEncodeError> { - if message.recipients.is_empty() { - return Err(EventEncodeError::EmptyRequiredField("recipients")); - } - - let mut tags = Vec::with_capacity( - message.recipients.len() - + usize::from(message.reply_to.is_some()) - + usize::from(message.subject.is_some()), - ); - - for recipient in &message.recipients { - validate_recipient(recipient)?; - let mut tag = Vec::with_capacity(3); - tag.push("p".to_string()); - tag.push(recipient.public_key.clone()); - if let Some(relay_url) = &recipient.relay_url { - tag.push(relay_url.clone()); - } - tags.push(tag); - } - - if let Some(reply_to) = &message.reply_to { - if reply_to.id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("reply_to.id")); - } - let mut tag = Vec::with_capacity(3); - tag.push("e".to_string()); - tag.push(reply_to.id.clone()); - if let Some(relay) = &reply_to.relays { - if relay.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("reply_to.relays")); - } - tag.push(relay.clone()); - } + let mut tags = build_recipient_tags(&message.recipients)?; + if let Some(tag) = build_reply_tag(&message.reply_to)? { tags.push(tag); } - - if let Some(subject) = &message.subject { - if subject.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("subject")); - } - let mut tag = Vec::with_capacity(2); - tag.push("subject".to_string()); - tag.push(subject.clone()); + if let Some(tag) = build_subject_tag(&message.subject)? { tags.push(tag); } - Ok(tags) } diff --git a/events-codec/src/message/mod.rs b/events-codec/src/message/mod.rs @@ -2,3 +2,4 @@ pub mod decode; pub mod encode; +pub(crate) mod tags; diff --git a/events-codec/src/message/tags.rs b/events-codec/src/message/tags.rs @@ -0,0 +1,156 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; +#[cfg(not(feature = "std"))] +use alloc::vec; + +use radroots_events::{RadrootsNostrEventPtr, message::RadrootsMessageRecipient}; + +use crate::error::{EventEncodeError, EventParseError}; + +fn validate_recipient(recipient: &RadrootsMessageRecipient) -> Result<(), EventEncodeError> { + if recipient.public_key.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipients.public_key")); + } + if let Some(relay_url) = &recipient.relay_url { + if relay_url.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipients.relay_url")); + } + } + Ok(()) +} + +pub(crate) fn build_recipient_tags( + recipients: &[RadrootsMessageRecipient], +) -> Result<Vec<Vec<String>>, EventEncodeError> { + if recipients.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipients")); + } + + let mut tags = Vec::with_capacity(recipients.len()); + for recipient in recipients { + validate_recipient(recipient)?; + let mut tag = Vec::with_capacity(3); + tag.push("p".to_string()); + tag.push(recipient.public_key.clone()); + if let Some(relay_url) = &recipient.relay_url { + tag.push(relay_url.clone()); + } + tags.push(tag); + } + Ok(tags) +} + +pub(crate) fn build_reply_tag( + reply_to: &Option<RadrootsNostrEventPtr>, +) -> Result<Option<Vec<String>>, EventEncodeError> { + let reply_to = match reply_to { + Some(reply_to) => reply_to, + None => return Ok(None), + }; + if reply_to.id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("reply_to.id")); + } + let mut tag = Vec::with_capacity(3); + tag.push("e".to_string()); + tag.push(reply_to.id.clone()); + if let Some(relay) = &reply_to.relays { + if relay.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("reply_to.relays")); + } + tag.push(relay.clone()); + } + Ok(Some(tag)) +} + +pub(crate) fn build_subject_tag( + subject: &Option<String>, +) -> Result<Option<Vec<String>>, EventEncodeError> { + let subject = match subject { + Some(subject) => subject, + None => return Ok(None), + }; + if subject.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("subject")); + } + Ok(Some(vec!["subject".to_string(), subject.clone()])) +} + +fn parse_recipient_tag(tag: &[String]) -> Result<RadrootsMessageRecipient, EventParseError> { + if tag.get(0).map(|s| s.as_str()) != Some("p") { + return Err(EventParseError::InvalidTag("p")); + } + let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; + if public_key.trim().is_empty() { + return Err(EventParseError::InvalidTag("p")); + } + let relay_url = match tag.get(2) { + Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("p")), + Some(value) => Some(value.clone()), + None => None, + }; + Ok(RadrootsMessageRecipient { + public_key: public_key.clone(), + relay_url, + }) +} + +pub(crate) fn parse_recipients( + tags: &[Vec<String>], +) -> Result<Vec<RadrootsMessageRecipient>, EventParseError> { + let mut recipients = Vec::new(); + for tag in tags.iter().filter(|t| t.get(0).map(|s| s.as_str()) == Some("p")) { + recipients.push(parse_recipient_tag(tag)?); + } + if recipients.is_empty() { + return Err(EventParseError::MissingTag("p")); + } + Ok(recipients) +} + +pub(crate) fn parse_reply_tag( + tags: &[Vec<String>], +) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> { + let tag = match tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("e")) + { + Some(tag) => tag, + None => return Ok(None), + }; + if tag.get(0).map(|s| s.as_str()) != Some("e") { + return Err(EventParseError::InvalidTag("e")); + } + let id = tag.get(1).ok_or(EventParseError::InvalidTag("e"))?; + if id.trim().is_empty() { + return Err(EventParseError::InvalidTag("e")); + } + let relay = match tag.get(2) { + Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("e")), + Some(value) => Some(value.clone()), + None => None, + }; + Ok(Some(RadrootsNostrEventPtr { + id: id.clone(), + relays: relay, + })) +} + +pub(crate) fn parse_subject_tag( + tags: &[Vec<String>], +) -> Result<Option<String>, EventParseError> { + let tag = match tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("subject")) + { + Some(tag) => tag, + None => return Ok(None), + }; + if tag.get(0).map(|s| s.as_str()) != Some("subject") { + return Err(EventParseError::InvalidTag("subject")); + } + let subject = tag.get(1).ok_or(EventParseError::InvalidTag("subject"))?; + if subject.trim().is_empty() { + return Err(EventParseError::InvalidTag("subject")); + } + Ok(Some(subject.clone())) +} diff --git a/events-codec/src/message_file/decode.rs b/events-codec/src/message_file/decode.rs @@ -0,0 +1,184 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + kinds::KIND_MESSAGE_FILE, + message_file::{ + RadrootsMessageFile, RadrootsMessageFileDimensions, RadrootsMessageFileEventIndex, + RadrootsMessageFileEventMetadata, + }, +}; + +use crate::error::EventParseError; +use crate::message::tags::{parse_recipients, parse_reply_tag, parse_subject_tag}; + +const DEFAULT_KIND: u32 = KIND_MESSAGE_FILE; + +fn required_tag_value(tags: &[Vec<String>], key: &'static str) -> Result<String, EventParseError> { + let value = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(key)) + .and_then(|t| t.get(1)); + let value = value.ok_or(EventParseError::MissingTag(key))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(key)); + } + Ok(value.clone()) +} + +fn optional_tag_value(tags: &[Vec<String>], key: &'static str) -> Result<Option<String>, EventParseError> { + let value = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(key)) + .and_then(|t| t.get(1)); + match value { + Some(value) if value.trim().is_empty() => Err(EventParseError::InvalidTag(key)), + Some(value) => Ok(Some(value.clone())), + None => Ok(None), + } +} + +fn parse_dimensions(value: &str) -> Result<RadrootsMessageFileDimensions, EventParseError> { + let (w, h) = value.split_once('x').ok_or(EventParseError::InvalidTag("dim"))?; + let w = w.parse::<u32>().map_err(|_| EventParseError::InvalidTag("dim"))?; + let h = h.parse::<u32>().map_err(|_| EventParseError::InvalidTag("dim"))?; + Ok(RadrootsMessageFileDimensions { w, h }) +} + +fn parse_size(tags: &[Vec<String>]) -> Result<Option<u64>, EventParseError> { + let value = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("size")) + .and_then(|t| t.get(1)); + let Some(value) = value else { return Ok(None); }; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("size")); + } + let size = value + .parse::<u64>() + .map_err(|e| EventParseError::InvalidNumber("size", e))?; + Ok(Some(size)) +} + +fn parse_dimensions_tag(tags: &[Vec<String>]) -> Result<Option<RadrootsMessageFileDimensions>, EventParseError> { + let value = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("dim")) + .and_then(|t| t.get(1)); + let Some(value) = value else { return Ok(None); }; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("dim")); + } + Ok(Some(parse_dimensions(value)?)) +} + +fn parse_fallbacks(tags: &[Vec<String>]) -> Result<Vec<String>, EventParseError> { + let mut fallbacks = Vec::new(); + for tag in tags.iter().filter(|t| t.get(0).map(|s| s.as_str()) == Some("fallback")) { + let value = tag.get(1).ok_or(EventParseError::InvalidTag("fallback"))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("fallback")); + } + fallbacks.push(value.clone()); + } + Ok(fallbacks) +} + +pub fn message_file_from_tags( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsMessageFile, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "15", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + + let recipients = parse_recipients(tags)?; + let reply_to = parse_reply_tag(tags)?; + let subject = parse_subject_tag(tags)?; + let file_type = required_tag_value(tags, "file-type")?; + let encryption_algorithm = required_tag_value(tags, "encryption-algorithm")?; + let decryption_key = required_tag_value(tags, "decryption-key")?; + let decryption_nonce = required_tag_value(tags, "decryption-nonce")?; + let encrypted_hash = required_tag_value(tags, "x")?; + let original_hash = optional_tag_value(tags, "ox")?; + let size = parse_size(tags)?; + let dimensions = parse_dimensions_tag(tags)?; + let blurhash = optional_tag_value(tags, "blurhash")?; + let thumb = optional_tag_value(tags, "thumb")?; + let fallbacks = parse_fallbacks(tags)?; + + Ok(RadrootsMessageFile { + recipients, + file_url: content.to_string(), + reply_to, + subject, + file_type, + encryption_algorithm, + decryption_key, + decryption_nonce, + encrypted_hash, + original_hash, + size, + dimensions, + blurhash, + thumb, + fallbacks, + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsMessageFileEventMetadata, EventParseError> { + let message_file = message_file_from_tags(kind, &tags, &content)?; + Ok(RadrootsMessageFileEventMetadata { + id, + author, + published_at, + kind, + message_file, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsMessageFileEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsMessageFileEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/message_file/encode.rs b/events-codec/src/message_file/encode.rs @@ -0,0 +1,121 @@ +#[cfg(not(feature = "std"))] +use alloc::{format, string::{String, ToString}, vec::Vec}; +#[cfg(not(feature = "std"))] +use alloc::vec; + +use radroots_events::kinds::KIND_MESSAGE_FILE; +use radroots_events::message_file::{RadrootsMessageFile, RadrootsMessageFileDimensions}; + +use crate::error::EventEncodeError; +use crate::message::tags::{build_recipient_tags, build_reply_tag, build_subject_tag}; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = KIND_MESSAGE_FILE; + +fn validate_required(value: &str, field: &'static str) -> Result<(), EventEncodeError> { + if value.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField(field)); + } + Ok(()) +} + +fn push_required_tag( + tags: &mut Vec<Vec<String>>, + key: &'static str, + value: &str, + field: &'static str, +) -> Result<(), EventEncodeError> { + validate_required(value, field)?; + tags.push(vec![key.to_string(), value.to_string()]); + Ok(()) +} + +fn push_optional_tag(tags: &mut Vec<Vec<String>>, key: &'static str, value: &Option<String>) { + if let Some(value) = value { + tags.push(vec![key.to_string(), value.clone()]); + } +} + +fn push_dimensions_tag( + tags: &mut Vec<Vec<String>>, + dimensions: &Option<RadrootsMessageFileDimensions>, +) { + if let Some(dimensions) = dimensions { + tags.push(vec![ + "dim".to_string(), + format!("{}x{}", dimensions.w, dimensions.h), + ]); + } +} + +pub fn message_file_build_tags( + message: &RadrootsMessageFile, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = build_recipient_tags(&message.recipients)?; + if let Some(tag) = build_reply_tag(&message.reply_to)? { + tags.push(tag); + } + if let Some(tag) = build_subject_tag(&message.subject)? { + tags.push(tag); + } + + push_required_tag( + &mut tags, + "file-type", + &message.file_type, + "file_type", + )?; + push_required_tag( + &mut tags, + "encryption-algorithm", + &message.encryption_algorithm, + "encryption_algorithm", + )?; + push_required_tag( + &mut tags, + "decryption-key", + &message.decryption_key, + "decryption_key", + )?; + push_required_tag( + &mut tags, + "decryption-nonce", + &message.decryption_nonce, + "decryption_nonce", + )?; + push_required_tag(&mut tags, "x", &message.encrypted_hash, "encrypted_hash")?; + + push_optional_tag(&mut tags, "ox", &message.original_hash); + if let Some(size) = message.size { + tags.push(vec!["size".to_string(), size.to_string()]); + } + push_dimensions_tag(&mut tags, &message.dimensions); + push_optional_tag(&mut tags, "blurhash", &message.blurhash); + push_optional_tag(&mut tags, "thumb", &message.thumb); + for fallback in &message.fallbacks { + validate_required(fallback, "fallback")?; + tags.push(vec!["fallback".to_string(), fallback.clone()]); + } + + Ok(tags) +} + +pub fn to_wire_parts(message: &RadrootsMessageFile) -> Result<WireEventParts, EventEncodeError> { + validate_required(&message.file_url, "file_url")?; + let tags = message_file_build_tags(message)?; + Ok(WireEventParts { + kind: DEFAULT_KIND, + content: message.file_url.clone(), + tags, + }) +} + +pub fn to_wire_parts_with_kind( + message: &RadrootsMessageFile, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != DEFAULT_KIND { + return Err(EventEncodeError::InvalidKind(kind)); + } + to_wire_parts(message) +} diff --git a/events-codec/src/message/mod.rs b/events-codec/src/message_file/mod.rs diff --git a/events-codec/src/seal/decode.rs b/events-codec/src/seal/decode.rs @@ -0,0 +1,83 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + kinds::KIND_SEAL, + seal::{RadrootsSeal, RadrootsSealEventIndex, RadrootsSealEventMetadata}, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = KIND_SEAL; + +pub fn seal_from_parts( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsSeal, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "13", + got: kind, + }); + } + if !tags.is_empty() { + return Err(EventParseError::InvalidTag("tags")); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + Ok(RadrootsSeal { + content: content.to_string(), + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsSealEventMetadata, EventParseError> { + let seal = seal_from_parts(kind, &tags, &content)?; + Ok(RadrootsSealEventMetadata { + id, + author, + published_at, + kind, + seal, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsSealEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsSealEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/seal/encode.rs b/events-codec/src/seal/encode.rs @@ -0,0 +1,36 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::kinds::KIND_SEAL; +use radroots_events::seal::RadrootsSeal; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = KIND_SEAL; + +pub fn seal_build_tags(_seal: &RadrootsSeal) -> Result<Vec<Vec<String>>, EventEncodeError> { + Ok(Vec::new()) +} + +pub fn to_wire_parts(seal: &RadrootsSeal) -> Result<WireEventParts, EventEncodeError> { + if seal.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + let tags = seal_build_tags(seal)?; + Ok(WireEventParts { + kind: DEFAULT_KIND, + content: seal.content.clone(), + tags, + }) +} + +pub fn to_wire_parts_with_kind( + seal: &RadrootsSeal, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != DEFAULT_KIND { + return Err(EventEncodeError::InvalidKind(kind)); + } + to_wire_parts(seal) +} diff --git a/events-codec/src/message/mod.rs b/events-codec/src/seal/mod.rs diff --git a/events-codec/src/tag_builders.rs b/events-codec/src/tag_builders.rs @@ -9,6 +9,7 @@ use radroots_events::{ app_data::RadrootsAppData, comment::RadrootsComment, follow::RadrootsFollow, + gift_wrap::RadrootsGiftWrap, job_feedback::RadrootsJobFeedback, job_request::RadrootsJobRequest, job_result::RadrootsJobResult, @@ -16,9 +17,11 @@ use radroots_events::{ list::RadrootsList, list_set::RadrootsListSet, message::RadrootsMessage, + message_file::RadrootsMessageFile, post::RadrootsPost, profile::RadrootsProfile, reaction::RadrootsReaction, + seal::RadrootsSeal, }; use crate::comment::encode::comment_build_tags; @@ -33,7 +36,10 @@ use crate::listing::tags::listing_tags; use crate::list::encode::list_build_tags; use crate::list_set::encode::list_set_build_tags; use crate::message::encode::message_build_tags; +use crate::message_file::encode::message_file_build_tags; use crate::reaction::encode::reaction_build_tags; +use crate::gift_wrap::encode::gift_wrap_build_tags; +use crate::seal::encode::seal_build_tags; pub trait RadrootsEventTagBuilder { type Error; @@ -80,6 +86,14 @@ impl RadrootsEventTagBuilder for RadrootsMessage { } } +impl RadrootsEventTagBuilder for RadrootsMessageFile { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + message_file_build_tags(self) + } +} + impl RadrootsEventTagBuilder for RadrootsFollow { type Error = EventEncodeError; @@ -131,6 +145,22 @@ impl RadrootsEventTagBuilder for RadrootsJobFeedback { } } +impl RadrootsEventTagBuilder for RadrootsSeal { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + seal_build_tags(self) + } +} + +impl RadrootsEventTagBuilder for RadrootsGiftWrap { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + gift_wrap_build_tags(self) + } +} + impl RadrootsEventTagBuilder for RadrootsProfile { type Error = Infallible; diff --git a/events-codec/tests/gift_wrap.rs b/events-codec/tests/gift_wrap.rs @@ -0,0 +1,70 @@ +use radroots_events::gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapRecipient}; +use radroots_events::kinds::{KIND_GIFT_WRAP, KIND_MESSAGE}; + +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::gift_wrap::decode::gift_wrap_from_tags; +use radroots_events_codec::gift_wrap::encode::{gift_wrap_build_tags, to_wire_parts}; + +fn sample_gift_wrap() -> RadrootsGiftWrap { + RadrootsGiftWrap { + recipient: RadrootsGiftWrapRecipient { + public_key: "pubkey".to_string(), + relay_url: Some("wss://relay.example".to_string()), + }, + content: "encrypted".to_string(), + expiration: Some(1700000000), + } +} + +#[test] +fn gift_wrap_build_tags_requires_recipient() { + let mut gift_wrap = sample_gift_wrap(); + gift_wrap.recipient.public_key = " ".to_string(); + + let err = gift_wrap_build_tags(&gift_wrap).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("recipient.public_key") + )); +} + +#[test] +fn gift_wrap_to_wire_parts_sets_kind_content_and_tags() { + let gift_wrap = sample_gift_wrap(); + let parts = to_wire_parts(&gift_wrap).unwrap(); + + assert_eq!(parts.kind, KIND_GIFT_WRAP); + assert_eq!(parts.content, "encrypted"); + assert_eq!( + parts.tags, + vec![ + vec![ + "p".to_string(), + "pubkey".to_string(), + "wss://relay.example".to_string() + ], + vec!["expiration".to_string(), "1700000000".to_string()], + ] + ); +} + +#[test] +fn gift_wrap_from_tags_rejects_wrong_kind() { + let gift_wrap = sample_gift_wrap(); + let parts = to_wire_parts(&gift_wrap).unwrap(); + + let err = gift_wrap_from_tags(KIND_MESSAGE, &parts.tags, &parts.content).unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "1059", + got: KIND_MESSAGE + } + )); +} + +#[test] +fn gift_wrap_from_tags_requires_p_tag() { + let err = gift_wrap_from_tags(KIND_GIFT_WRAP, &[], "payload").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag("p"))); +} diff --git a/events-codec/tests/message_file.rs b/events-codec/tests/message_file.rs @@ -0,0 +1,159 @@ +use radroots_events::kinds::{KIND_MESSAGE, KIND_MESSAGE_FILE}; +use radroots_events::message::RadrootsMessageRecipient; +use radroots_events::message_file::{RadrootsMessageFile, RadrootsMessageFileDimensions}; +use radroots_events::RadrootsNostrEventPtr; + +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::message_file::decode::message_file_from_tags; +use radroots_events_codec::message_file::encode::{message_file_build_tags, to_wire_parts}; + +fn sample_message_file() -> RadrootsMessageFile { + RadrootsMessageFile { + recipients: vec![ + RadrootsMessageRecipient { + public_key: "pub1".to_string(), + relay_url: None, + }, + RadrootsMessageRecipient { + public_key: "pub2".to_string(), + relay_url: Some("wss://relay.example".to_string()), + }, + ], + file_url: "https://files.example/encrypted.bin".to_string(), + reply_to: Some(RadrootsNostrEventPtr { + id: "reply".to_string(), + relays: Some("wss://reply.example".to_string()), + }), + subject: Some("topic".to_string()), + file_type: "image/jpeg".to_string(), + encryption_algorithm: "aes-gcm".to_string(), + decryption_key: "key".to_string(), + decryption_nonce: "nonce".to_string(), + encrypted_hash: "hash".to_string(), + original_hash: Some("orig-hash".to_string()), + size: Some(1200), + dimensions: Some(RadrootsMessageFileDimensions { w: 1200, h: 800 }), + blurhash: Some("blurhash".to_string()), + thumb: Some("https://files.example/thumb.bin".to_string()), + fallbacks: vec![ + "https://files.example/fallback-1.bin".to_string(), + "https://files.example/fallback-2.bin".to_string(), + ], + } +} + +#[test] +fn message_file_build_tags_requires_recipients() { + let mut message = sample_message_file(); + message.recipients.clear(); + + let err = message_file_build_tags(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("recipients") + )); +} + +#[test] +fn message_file_to_wire_parts_requires_file_url() { + let mut message = sample_message_file(); + message.file_url = " ".to_string(); + + let err = to_wire_parts(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("file_url") + )); +} + +#[test] +fn message_file_build_tags_requires_file_type() { + let mut message = sample_message_file(); + message.file_type = " ".to_string(); + + let err = message_file_build_tags(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("file_type") + )); +} + +#[test] +fn message_file_to_wire_parts_sets_kind_content_and_tags() { + let message = sample_message_file(); + let parts = to_wire_parts(&message).unwrap(); + + assert_eq!(parts.kind, KIND_MESSAGE_FILE); + assert_eq!(parts.content, message.file_url); + assert_eq!( + parts.tags, + vec![ + vec!["p".to_string(), "pub1".to_string()], + vec![ + "p".to_string(), + "pub2".to_string(), + "wss://relay.example".to_string() + ], + vec![ + "e".to_string(), + "reply".to_string(), + "wss://reply.example".to_string() + ], + vec!["subject".to_string(), "topic".to_string()], + vec!["file-type".to_string(), "image/jpeg".to_string()], + vec!["encryption-algorithm".to_string(), "aes-gcm".to_string()], + vec!["decryption-key".to_string(), "key".to_string()], + vec!["decryption-nonce".to_string(), "nonce".to_string()], + vec!["x".to_string(), "hash".to_string()], + vec!["ox".to_string(), "orig-hash".to_string()], + vec!["size".to_string(), "1200".to_string()], + vec!["dim".to_string(), "1200x800".to_string()], + vec!["blurhash".to_string(), "blurhash".to_string()], + vec!["thumb".to_string(), "https://files.example/thumb.bin".to_string()], + vec![ + "fallback".to_string(), + "https://files.example/fallback-1.bin".to_string() + ], + vec![ + "fallback".to_string(), + "https://files.example/fallback-2.bin".to_string() + ], + ] + ); +} + +#[test] +fn message_file_roundtrip_from_tags() { + let message = sample_message_file(); + let parts = to_wire_parts(&message).unwrap(); + + let decoded = message_file_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.file_url, message.file_url); + assert_eq!(decoded.file_type, message.file_type); + assert_eq!(decoded.encryption_algorithm, message.encryption_algorithm); + assert_eq!(decoded.decryption_key, message.decryption_key); + assert_eq!(decoded.decryption_nonce, message.decryption_nonce); + assert_eq!(decoded.encrypted_hash, message.encrypted_hash); + assert_eq!(decoded.original_hash, message.original_hash); + assert_eq!(decoded.size, message.size); + assert_eq!(decoded.dimensions, message.dimensions); + assert_eq!(decoded.blurhash, message.blurhash); + assert_eq!(decoded.thumb, message.thumb); + assert_eq!(decoded.fallbacks, message.fallbacks); + assert_eq!(decoded.recipients.len(), message.recipients.len()); +} + +#[test] +fn message_file_from_tags_rejects_wrong_kind() { + let message = sample_message_file(); + let parts = to_wire_parts(&message).unwrap(); + + let err = message_file_from_tags(KIND_MESSAGE, &parts.tags, &parts.content).unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "15", + got: KIND_MESSAGE + } + )); +} diff --git a/events-codec/tests/seal.rs b/events-codec/tests/seal.rs @@ -0,0 +1,50 @@ +use radroots_events::kinds::{KIND_MESSAGE, KIND_SEAL}; +use radroots_events::seal::RadrootsSeal; + +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::seal::decode::seal_from_parts; +use radroots_events_codec::seal::encode::to_wire_parts; + +#[test] +fn seal_to_wire_parts_requires_content() { + let seal = RadrootsSeal { + content: " ".to_string(), + }; + + let err = to_wire_parts(&seal).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("content") + )); +} + +#[test] +fn seal_to_wire_parts_sets_kind_and_content() { + let seal = RadrootsSeal { + content: "payload".to_string(), + }; + + let parts = to_wire_parts(&seal).unwrap(); + assert_eq!(parts.kind, KIND_SEAL); + assert_eq!(parts.content, "payload"); + assert!(parts.tags.is_empty()); +} + +#[test] +fn seal_from_parts_rejects_wrong_kind() { + let err = seal_from_parts(KIND_MESSAGE, &[], "payload").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "13", + got: KIND_MESSAGE + } + )); +} + +#[test] +fn seal_from_parts_requires_empty_tags() { + let err = seal_from_parts(KIND_SEAL, &[vec!["p".to_string(), "x".to_string()]], "payload") + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("tags"))); +} diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts @@ -28,6 +28,14 @@ export type RadrootsFollowEventMetadata = { id: string, author: string, publishe export type RadrootsFollowProfile = { published_at: number, public_key: string, relay_url?: string | null, contact_name?: string | null, }; +export type RadrootsGiftWrap = { recipient: RadrootsGiftWrapRecipient, content: string, expiration?: number | null, }; + +export type RadrootsGiftWrapEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsGiftWrapEventMetadata, }; + +export type RadrootsGiftWrapEventMetadata = { id: string, author: string, published_at: number, kind: number, gift_wrap: RadrootsGiftWrap, }; + +export type RadrootsGiftWrapRecipient = { public_key: string, relay_url?: string | null, }; + export type RadrootsJobFeedback = { kind: number, status: JobFeedbackStatus, extra_info?: string | null, request_event: RadrootsNostrEventPtr, customer_pubkey?: string | null, payment?: JobPaymentRequest | null, content?: string | null, encrypted: boolean, }; export type RadrootsJobFeedbackEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsJobFeedbackEventMetadata, }; @@ -96,6 +104,14 @@ export type RadrootsMessageEventIndex = { event: RadrootsNostrEvent, metadata: R export type RadrootsMessageEventMetadata = { id: string, author: string, published_at: number, kind: number, message: RadrootsMessage, }; +export type RadrootsMessageFile = { recipients: Array<RadrootsMessageRecipient>, file_url: string, reply_to?: RadrootsNostrEventPtr | null, subject?: string | null, file_type: string, encryption_algorithm: string, decryption_key: string, decryption_nonce: string, encrypted_hash: string, original_hash?: string | null, size?: number | null, dimensions?: RadrootsMessageFileDimensions | null, blurhash?: string | null, thumb?: string | null, fallbacks: Array<string>, }; + +export type RadrootsMessageFileDimensions = { w: number, h: number, }; + +export type RadrootsMessageFileEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsMessageFileEventMetadata, }; + +export type RadrootsMessageFileEventMetadata = { id: string, author: string, published_at: number, kind: number, message_file: RadrootsMessageFile, }; + export type RadrootsMessageRecipient = { public_key: string, relay_url?: string | null, }; export type RadrootsNostrEvent = { id: string, author: string, created_at: number, kind: number, tags: Array<Array<string>>, content: string, sig: string, }; @@ -123,3 +139,9 @@ export type RadrootsReactionEventIndex = { event: RadrootsNostrEvent, metadata: export type RadrootsReactionEventMetadata = { id: string, author: string, published_at: number, kind: number, reaction: RadrootsReaction, }; export type RadrootsRelayDocument = { name?: string | null, description?: string | null, pubkey?: string | null, contact?: string | null, supported_nips?: number[] | null, software?: string | null, version?: string | null, }; + +export type RadrootsSeal = { content: string, }; + +export type RadrootsSealEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsSealEventMetadata, }; + +export type RadrootsSealEventMetadata = { id: string, author: string, published_at: number, kind: number, seal: RadrootsSeal, }; diff --git a/events/bindings/ts/src/typeshare-types.ts b/events/bindings/ts/src/typeshare-types.ts @@ -6,7 +6,10 @@ export const KIND_PROFILE: number = 0; export const KIND_POST: number = 1; export const KIND_FOLLOW: number = 3; export const KIND_REACTION: number = 7; +export const KIND_SEAL: number = 13; export const KIND_MESSAGE: number = 14; +export const KIND_MESSAGE_FILE: number = 15; +export const KIND_GIFT_WRAP: number = 1059; export const KIND_COMMENT: number = 1111; export const KIND_LIST_MUTE: number = 10000; export const KIND_LIST_PINNED_NOTES: number = 10001; diff --git a/events/src/gift_wrap.rs b/events/src/gift_wrap.rs @@ -0,0 +1,50 @@ +#![forbid(unsafe_code)] + +use crate::RadrootsNostrEvent; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg(not(feature = "std"))] +use alloc::string::String; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGiftWrapEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsGiftWrapEventMetadata, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGiftWrapEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub gift_wrap: RadrootsGiftWrap, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGiftWrap { + pub recipient: RadrootsGiftWrapRecipient, + pub content: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number | null"))] + pub expiration: Option<u32>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGiftWrapRecipient { + pub public_key: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub relay_url: Option<String>, +} diff --git a/events/src/kinds.rs b/events/src/kinds.rs @@ -7,8 +7,14 @@ pub const KIND_FOLLOW: u32 = 3; #[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_REACTION: u32 = 7; #[cfg_attr(feature = "typeshare", typeshare::typeshare)] +pub const KIND_SEAL: u32 = 13; +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_MESSAGE: u32 = 14; #[cfg_attr(feature = "typeshare", typeshare::typeshare)] +pub const KIND_MESSAGE_FILE: u32 = 15; +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] +pub const KIND_GIFT_WRAP: u32 = 1059; +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_COMMENT: u32 = 1111; #[cfg_attr(feature = "typeshare", typeshare::typeshare)] pub const KIND_LIST_MUTE: u32 = 10000; diff --git a/events/src/lib.rs b/events/src/lib.rs @@ -11,6 +11,7 @@ use alloc::{string::String, vec::Vec}; pub mod comment; pub mod follow; +pub mod gift_wrap; pub mod job; pub mod job_feedback; pub mod job_request; @@ -21,10 +22,12 @@ pub mod list; pub mod list_set; pub mod app_data; pub mod message; +pub mod message_file; pub mod post; pub mod profile; pub mod reaction; pub mod relay_document; +pub mod seal; pub mod tags; #[cfg_attr(feature = "ts-rs", derive(TS))] diff --git a/events/src/message_file.rs b/events/src/message_file.rs @@ -0,0 +1,68 @@ +#![forbid(unsafe_code)] + +use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr}; +use crate::message::RadrootsMessageRecipient; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessageFileEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsMessageFileEventMetadata, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessageFileEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub message_file: RadrootsMessageFile, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessageFile { + pub recipients: Vec<RadrootsMessageRecipient>, + pub file_url: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))] + pub reply_to: Option<RadrootsNostrEventPtr>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub subject: Option<String>, + pub file_type: String, + pub encryption_algorithm: String, + pub decryption_key: String, + pub decryption_nonce: String, + pub encrypted_hash: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub original_hash: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number | null"))] + pub size: Option<u64>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsMessageFileDimensions | null"))] + pub dimensions: Option<RadrootsMessageFileDimensions>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub blurhash: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub thumb: Option<String>, + pub fallbacks: Vec<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsMessageFileDimensions { + pub w: u32, + pub h: u32, +} diff --git a/events/src/seal.rs b/events/src/seal.rs @@ -0,0 +1,37 @@ +#![forbid(unsafe_code)] + +use crate::RadrootsNostrEvent; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg(not(feature = "std"))] +use alloc::string::String; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsSealEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsSealEventMetadata, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsSealEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub seal: RadrootsSeal, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsSeal { + pub content: String, +} diff --git a/events/src/typeshare_kinds.rs b/events/src/typeshare_kinds.rs @@ -7,8 +7,14 @@ pub const KIND_FOLLOW: u32 = 3; #[typeshare::typeshare] pub const KIND_REACTION: u32 = 7; #[typeshare::typeshare] +pub const KIND_SEAL: u32 = 13; +#[typeshare::typeshare] pub const KIND_MESSAGE: u32 = 14; #[typeshare::typeshare] +pub const KIND_MESSAGE_FILE: u32 = 15; +#[typeshare::typeshare] +pub const KIND_GIFT_WRAP: u32 = 1059; +#[typeshare::typeshare] pub const KIND_COMMENT: u32 = 1111; #[typeshare::typeshare] pub const KIND_LIST_MUTE: u32 = 10000; diff --git a/identity/Cargo.toml b/identity/Cargo.toml @@ -12,7 +12,7 @@ std = ["dep:radroots-runtime"] [dependencies] radroots-runtime = { workspace = true, optional = true } -nostr = { workspace = true } +nostr = { workspace = true, features = ["os-rng"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/nostr/Cargo.toml b/nostr/Cargo.toml @@ -13,6 +13,7 @@ client = ["std", "dep:nostr-sdk", "dep:radroots-identity"] codec = ["dep:radroots-events", "dep:radroots-events-codec"] events = ["dep:radroots-events", "radroots-events/std", "radroots-events/serde"] http = ["dep:reqwest"] +nip17 = ["std", "codec", "nostr/nip44", "nostr/nip59", "nostr/os-rng"] [dependencies] radroots-events = { workspace = true, optional = true, default-features = false } @@ -24,3 +25,6 @@ reqwest = { workspace = true, optional = true, default-features = false, feature serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/nostr/src/lib.rs b/nostr/src/lib.rs @@ -21,6 +21,9 @@ pub mod codec_adapters; #[cfg(feature = "codec")] pub mod job_adapter; +#[cfg(feature = "nip17")] +pub mod nip17; + #[cfg(feature = "http")] pub mod nip11; @@ -111,6 +114,16 @@ pub mod prelude { }; pub use crate::util::radroots_nostr_npub_string; + #[cfg(feature = "nip17")] + pub use crate::nip17::{ + radroots_nostr_unwrap_gift_wrap, + radroots_nostr_wrap_message, + radroots_nostr_wrap_message_file, + RadrootsNip17Error, + RadrootsNip17Rumor, + RadrootsNip17WrapOptions, + }; + #[cfg(feature = "http")] pub use crate::nip11::fetch_nip11; diff --git a/nostr/src/nip17.rs b/nostr/src/nip17.rs @@ -0,0 +1,324 @@ +#![forbid(unsafe_code)] + +extern crate alloc; + +use alloc::{string::String, vec::Vec}; + +use nostr::{ + Event, + EventBuilder, + Kind, + NostrSigner, + PublicKey, + Tag, + TagKind, + Timestamp, + UnsignedEvent, +}; +use nostr::nips::nip59; +use thiserror::Error; + +use radroots_events::kinds::{KIND_MESSAGE, KIND_MESSAGE_FILE}; +use radroots_events::message::{RadrootsMessage, RadrootsMessageEventMetadata}; +use radroots_events::message_file::{RadrootsMessageFile, RadrootsMessageFileEventMetadata}; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::message::decode as message_decode; +use radroots_events_codec::message::encode as message_encode; +use radroots_events_codec::message_file::decode as message_file_decode; +use radroots_events_codec::message_file::encode as message_file_encode; +use radroots_events_codec::wire::WireEventParts; + +use crate::util::created_at_u32_saturating; + +#[derive(Debug, Error)] +pub enum RadrootsNip17Error { + #[error("Message encode error: {0}")] + MessageEncode(#[from] EventEncodeError), + #[error("Message decode error: {0}")] + MessageDecode(#[from] EventParseError), + #[error("NIP-59 error: {0}")] + Nip59(#[from] nip59::Error), + #[error("Event builder error: {0}")] + EventBuilder(#[from] nostr::event::builder::Error), + #[error("Signer error: {0}")] + Signer(#[from] nostr::signer::SignerError), + #[error("Key error: {0}")] + Key(#[from] nostr::key::Error), + #[error("Unsupported rumor kind: {0}")] + UnsupportedRumorKind(u32), +} + +#[derive(Clone, Debug)] +pub enum RadrootsNip17Rumor { + Message(RadrootsMessageEventMetadata), + MessageFile(RadrootsMessageFileEventMetadata), +} + +#[derive(Clone, Debug)] +pub struct RadrootsNip17WrapOptions { + pub include_sender: bool, + pub rumor_created_at: Option<u32>, + pub gift_wrap_tags: Vec<Vec<String>>, +} + +impl Default for RadrootsNip17WrapOptions { + fn default() -> Self { + Self { + include_sender: true, + rumor_created_at: None, + gift_wrap_tags: Vec::new(), + } + } +} + +fn tags_from_slices(tag_slices: &[Vec<String>]) -> Vec<Tag> { + let mut tags = Vec::with_capacity(tag_slices.len()); + for slice in tag_slices { + if slice.is_empty() { + continue; + } + let key = slice[0].clone(); + let values = slice[1..].to_vec(); + tags.push(Tag::custom(TagKind::Custom(key.into()), values)); + } + tags +} + +fn rumor_from_parts( + parts: WireEventParts, + author: PublicKey, + created_at: Option<u32>, +) -> UnsignedEvent { + let tags = tags_from_slices(&parts.tags); + let timestamp = match created_at { + Some(ts) => Timestamp::from_secs(ts as u64), + None => Timestamp::now(), + }; + let mut rumor = UnsignedEvent::new( + author, + timestamp, + Kind::Custom(parts.kind as u16), + tags, + parts.content, + ); + rumor.ensure_id(); + rumor +} + +fn parse_recipients(recipients: &[radroots_events::message::RadrootsMessageRecipient]) -> Result<Vec<PublicKey>, RadrootsNip17Error> { + let mut out = Vec::with_capacity(recipients.len()); + for recipient in recipients { + out.push(recipient.public_key.parse::<PublicKey>()?); + } + Ok(out) +} + +fn push_unique(recipients: &mut Vec<PublicKey>, pubkey: PublicKey) { + if recipients.iter().any(|r| r == &pubkey) { + return; + } + recipients.push(pubkey); +} + +async fn wrap_rumor<T>( + signer: &T, + rumor: UnsignedEvent, + mut recipients: Vec<PublicKey>, + options: &RadrootsNip17WrapOptions, +) -> Result<Vec<Event>, RadrootsNip17Error> +where + T: NostrSigner, +{ + let sender_pubkey = signer.get_public_key().await?; + if options.include_sender { + push_unique(&mut recipients, sender_pubkey); + } + let extra_tags = tags_from_slices(&options.gift_wrap_tags); + + let mut out = Vec::with_capacity(recipients.len()); + for recipient in recipients { + let event = EventBuilder::gift_wrap(signer, &recipient, rumor.clone(), extra_tags.clone()) + .await?; + out.push(event); + } + Ok(out) +} + +pub async fn radroots_nostr_wrap_message<T>( + signer: &T, + message: &RadrootsMessage, + options: RadrootsNip17WrapOptions, +) -> Result<Vec<Event>, RadrootsNip17Error> +where + T: NostrSigner, +{ + let parts = message_encode::to_wire_parts(message)?; + let author = signer.get_public_key().await?; + let rumor = rumor_from_parts(parts, author, options.rumor_created_at); + let recipients = parse_recipients(&message.recipients)?; + wrap_rumor(signer, rumor, recipients, &options).await +} + +pub async fn radroots_nostr_wrap_message_file<T>( + signer: &T, + message: &RadrootsMessageFile, + options: RadrootsNip17WrapOptions, +) -> Result<Vec<Event>, RadrootsNip17Error> +where + T: NostrSigner, +{ + let parts = message_file_encode::to_wire_parts(message)?; + let author = signer.get_public_key().await?; + let rumor = rumor_from_parts(parts, author, options.rumor_created_at); + let recipients = parse_recipients(&message.recipients)?; + wrap_rumor(signer, rumor, recipients, &options).await +} + +pub async fn radroots_nostr_unwrap_gift_wrap<T>( + signer: &T, + gift_wrap: &Event, +) -> Result<RadrootsNip17Rumor, RadrootsNip17Error> +where + T: NostrSigner, +{ + let unwrapped = nip59::extract_rumor(signer, gift_wrap).await?; + let mut rumor = unwrapped.rumor; + let id = rumor.id().to_string(); + let author = rumor.pubkey.to_string(); + let published_at = created_at_u32_saturating(rumor.created_at); + let kind = rumor.kind.as_u16() as u32; + let tags: Vec<Vec<String>> = rumor + .tags + .as_slice() + .iter() + .map(|t| t.as_slice().to_vec()) + .collect(); + let content = rumor.content.clone(); + + match kind { + KIND_MESSAGE => { + let metadata = message_decode::metadata_from_event( + id, + author, + published_at, + kind, + content, + tags, + )?; + Ok(RadrootsNip17Rumor::Message(metadata)) + } + KIND_MESSAGE_FILE => { + let metadata = message_file_decode::metadata_from_event( + id, + author, + published_at, + kind, + content, + tags, + )?; + Ok(RadrootsNip17Rumor::MessageFile(metadata)) + } + other => Err(RadrootsNip17Error::UnsupportedRumorKind(other)), + } +} + +#[cfg(all(test, feature = "nip17"))] +mod tests { + use super::*; + use nostr::Keys; + use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient}; + use radroots_events::message_file::{RadrootsMessageFile, RadrootsMessageFileDimensions}; + + fn sender_keys() -> Keys { + Keys::parse("6b911fd37cdf5c81d4c0adb1ab7fa822ed253ab0ad9aa18d77257c88b29b718e") + .unwrap() + } + + fn receiver_keys() -> Keys { + Keys::parse("7b911fd37cdf5c81d4c0adb1ab7fa822ed253ab0ad9aa18d77257c88b29b718e") + .unwrap() + } + + #[tokio::test] + async fn wrap_and_unwrap_message() { + let sender = sender_keys(); + let receiver = receiver_keys(); + let message = RadrootsMessage { + recipients: vec![RadrootsMessageRecipient { + public_key: receiver.public_key().to_string(), + relay_url: None, + }], + content: "hello".to_string(), + reply_to: None, + subject: None, + }; + let options = RadrootsNip17WrapOptions { + include_sender: false, + rumor_created_at: Some(1700000000), + gift_wrap_tags: Vec::new(), + }; + + let events = radroots_nostr_wrap_message(&sender, &message, options) + .await + .unwrap(); + assert_eq!(events.len(), 1); + + let rumor = radroots_nostr_unwrap_gift_wrap(&receiver, &events[0]) + .await + .unwrap(); + match rumor { + RadrootsNip17Rumor::Message(metadata) => { + assert_eq!(metadata.message.content, "hello"); + assert_eq!(metadata.message.recipients.len(), 1); + } + other => panic!("expected message rumor, got {other:?}"), + } + } + + #[tokio::test] + async fn wrap_and_unwrap_message_file() { + let sender = sender_keys(); + let receiver = receiver_keys(); + let message = RadrootsMessageFile { + recipients: vec![RadrootsMessageRecipient { + public_key: receiver.public_key().to_string(), + relay_url: None, + }], + file_url: "https://files.example/encrypted.bin".to_string(), + reply_to: None, + subject: None, + file_type: "image/jpeg".to_string(), + encryption_algorithm: "aes-gcm".to_string(), + decryption_key: "key".to_string(), + decryption_nonce: "nonce".to_string(), + encrypted_hash: "hash".to_string(), + original_hash: None, + size: Some(1200), + dimensions: Some(RadrootsMessageFileDimensions { w: 1200, h: 800 }), + blurhash: None, + thumb: None, + fallbacks: Vec::new(), + }; + let options = RadrootsNip17WrapOptions { + include_sender: false, + rumor_created_at: Some(1700000001), + gift_wrap_tags: Vec::new(), + }; + + let events = radroots_nostr_wrap_message_file(&sender, &message, options) + .await + .unwrap(); + assert_eq!(events.len(), 1); + + let rumor = radroots_nostr_unwrap_gift_wrap(&receiver, &events[0]) + .await + .unwrap(); + match rumor { + RadrootsNip17Rumor::MessageFile(metadata) => { + assert_eq!(metadata.message_file.file_url, message.file_url); + assert_eq!(metadata.message_file.encrypted_hash, message.encrypted_hash); + } + other => panic!("expected message file rumor, got {other:?}"), + } + } +} diff --git a/nostr/src/util.rs b/nostr/src/util.rs @@ -10,7 +10,7 @@ pub fn radroots_nostr_npub_string(pk: &RadrootsNostrPublicKey) -> Option<String> } pub fn created_at_u32_saturating(ts: RadrootsNostrTimestamp) -> u32 { - u32::try_from(ts.as_u64()).unwrap_or(u32::MAX) + u32::try_from(ts.as_secs()).unwrap_or(u32::MAX) } pub fn event_created_at_u32_saturating(event: &RadrootsNostrEvent) -> u32 {