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:
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 {