Skip to main content

nula_core/nips/
nip78.rs

1//! [NIP-78] Arbitrary Custom App Data.
2//!
3//! `kind: 30078` is the catch-all *addressable* event for
4//! application-specific blobs. The wire format is intentionally
5//! tiny: a `d` tag carries some reference to the app + context (the
6//! addressable identifier), and `.content` plus any extra tags hold
7//! whatever payload the app wants. Nostr serves as a "bring your own
8//! database" key/value store.
9//!
10//! # Why a typed wrapper at all
11//!
12//! Even though the spec leaves the body free-form, two patterns
13//! recur:
14//!
15//! 1. **App namespacing** — clients prefix the `d` tag with a
16//!    reverse-DNS or vendor identifier (`com.example.todoapp`,
17//!    `nostr-tools/v0`) so two unrelated apps never collide on the
18//!    same `(pubkey, 30078, d)` coordinate. The
19//!    [`ApplicationData`] builder accepts any string and does not
20//!    invent a format, but documents the convention.
21//! 2. **Forward-compatible extra tags** — apps frequently attach
22//!    bespoke tags they invent for their own bookkeeping. We
23//!    expose them through [`ApplicationData::extra_tags`] so
24//!    [`ApplicationData::from_event`] never silently drops anything.
25//!
26//! # Usage sketch
27//!
28//! ```no_run
29//! use nula_core::{EventBuilder, Keys};
30//! use nula_core::nips::nip78::ApplicationData;
31//!
32//! let keys = Keys::generate().unwrap();
33//! let app_data = ApplicationData::new("com.example.todoapp")
34//!     .content(r#"{"theme":"dark"}"#);
35//! let event = EventBuilder::application_data(&app_data)
36//!     .sign_with_keys(&keys)
37//!     .unwrap();
38//! ```
39//!
40//! [NIP-78]: https://github.com/nostr-protocol/nips/blob/master/78.md
41
42use thiserror::Error;
43
44use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
45
46/// `kind: 30078` — application-specific addressable event.
47pub const KIND_APPLICATION_DATA: Kind = Kind::new(30_078);
48
49/// Typed bundle for a NIP-78 `kind: 30078` event.
50///
51/// The `d` tag (= [`Self::identifier`]) is the only required wire
52/// element; everything else is opaque payload that the producing
53/// app interprets however it likes.
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct ApplicationData {
56    /// `d`-tag value. Spec recommends a reverse-DNS or vendor-prefixed
57    /// identifier such as `com.example.todoapp` so unrelated apps do
58    /// not collide on the same `(pubkey, 30078, d)` coordinate.
59    pub identifier: String,
60    /// Free-form `.content`. Often JSON, but any string works.
61    pub content: String,
62    /// Any additional tags the app stamped on the event. They are
63    /// preserved on a [`Self::from_event`] round-trip so consumers
64    /// never silently lose data.
65    pub extra_tags: Vec<Tag>,
66}
67
68impl ApplicationData {
69    /// Construct an empty bundle bound to `identifier`.
70    #[must_use]
71    pub fn new(identifier: impl Into<String>) -> Self {
72        Self {
73            identifier: identifier.into(),
74            content: String::new(),
75            extra_tags: Vec::new(),
76        }
77    }
78
79    /// Replace the content blob.
80    #[must_use]
81    pub fn content(mut self, content: impl Into<String>) -> Self {
82        self.content = content.into();
83        self
84    }
85
86    /// Append an app-defined tag.
87    ///
88    /// `d`-tags submitted here are silently dropped — the addressable
89    /// identifier is owned by [`Self::identifier`] and the builder
90    /// pins exactly one `d` tag at the head of the event.
91    #[must_use]
92    pub fn tag(mut self, tag: Tag) -> Self {
93        if !is_d_tag(&tag) {
94            self.extra_tags.push(tag);
95        }
96        self
97    }
98
99    /// Append several app-defined tags.
100    #[must_use]
101    pub fn tags<I>(mut self, tags: I) -> Self
102    where
103        I: IntoIterator<Item = Tag>,
104    {
105        for tag in tags {
106            if !is_d_tag(&tag) {
107                self.extra_tags.push(tag);
108            }
109        }
110        self
111    }
112
113    /// Parse a `kind: 30078` event back into a typed bundle.
114    ///
115    /// # Errors
116    ///
117    /// - [`ApplicationDataError::WrongKind`] for any other kind.
118    /// - [`ApplicationDataError::MissingIdentifier`] when no `d` tag
119    ///   is present (the event would not be addressable without one).
120    pub fn from_event(event: &Event) -> Result<Self, ApplicationDataError> {
121        if event.kind != KIND_APPLICATION_DATA {
122            return Err(ApplicationDataError::WrongKind(event.kind));
123        }
124        let identifier = d_value(&event.tags)
125            .ok_or(ApplicationDataError::MissingIdentifier)?
126            .to_owned();
127        let extra_tags: Vec<Tag> = event
128            .tags
129            .iter()
130            .filter(|tag| !is_d_tag(tag))
131            .cloned()
132            .collect();
133        Ok(Self {
134            identifier,
135            content: event.content.clone(),
136            extra_tags,
137        })
138    }
139}
140
141/// Errors raised by [`ApplicationData::from_event`].
142#[derive(Debug, Error)]
143#[non_exhaustive]
144pub enum ApplicationDataError {
145    /// The event was not `kind: 30078`.
146    #[error("expected kind 30078 (application data), got kind {}", .0.as_u16())]
147    WrongKind(Kind),
148    /// The `d` tag is absent.
149    #[error("NIP-78 event must carry a `d` tag")]
150    MissingIdentifier,
151}
152
153fn is_d_tag(tag: &Tag) -> bool {
154    matches!(
155        tag.kind(),
156        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D
157    )
158}
159
160fn d_value(tags: &Tags) -> Option<&str> {
161    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
162    tags.find_first(&head).and_then(|tag| tag.get(1))
163}
164
165impl EventBuilder {
166    /// Author a NIP-78 `kind: 30078` application-data event.
167    ///
168    /// The `d` tag is pinned at the head of the tag list; any extra
169    /// tags follow in the order they were attached to the bundle.
170    #[must_use]
171    pub fn application_data(data: &ApplicationData) -> Self {
172        let mut builder =
173            Self::new(KIND_APPLICATION_DATA, data.content.clone()).tag(Tag::d(&data.identifier));
174        for tag in &data.extra_tags {
175            builder = builder.tag(tag.clone());
176        }
177        builder
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::Keys;
185
186    fn keys() -> Keys {
187        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
188    }
189
190    #[test]
191    fn round_trip_with_only_required_fields() {
192        let data = ApplicationData::new("com.example.app");
193        let event = EventBuilder::application_data(&data)
194            .sign_with_keys(&keys())
195            .unwrap();
196        assert_eq!(event.kind, KIND_APPLICATION_DATA);
197        assert_eq!(event.tags.len(), 1, "exactly one tag: the d tag");
198        let parsed = ApplicationData::from_event(&event).unwrap();
199        assert_eq!(parsed, data);
200    }
201
202    #[test]
203    fn round_trip_preserves_content_and_extra_tags_in_order() {
204        let data = ApplicationData::new("vendor/v1")
205            .content(r#"{"theme":"dark"}"#)
206            .tag(Tag::with(&TagKind::Custom("color".to_owned()), ["blue"]))
207            .tag(Tag::with(&TagKind::Custom("color".to_owned()), ["red"]))
208            .tag(Tag::title("preferences"));
209        let event = EventBuilder::application_data(&data)
210            .sign_with_keys(&keys())
211            .unwrap();
212        let parsed = ApplicationData::from_event(&event).unwrap();
213        assert_eq!(parsed, data);
214        // Extra tags retain insertion order.
215        let names: Vec<&str> = parsed.extra_tags.iter().map(Tag::name).collect();
216        assert_eq!(names, ["color", "color", "title"]);
217    }
218
219    #[test]
220    fn user_supplied_d_tags_are_silently_dropped() {
221        let data = ApplicationData::new("vendor/v1")
222            .tag(Tag::d("not-the-real-id"))
223            .tag(Tag::title("ok"));
224        // Builder still pins the `vendor/v1` d-tag.
225        assert_eq!(data.extra_tags.len(), 1);
226        assert_eq!(data.extra_tags[0].name(), "title");
227    }
228
229    #[test]
230    fn missing_d_tag_is_rejected_when_parsing() {
231        let event = EventBuilder::new(KIND_APPLICATION_DATA, "")
232            .sign_with_keys(&keys())
233            .unwrap();
234        assert!(matches!(
235            ApplicationData::from_event(&event),
236            Err(ApplicationDataError::MissingIdentifier)
237        ));
238    }
239
240    #[test]
241    fn wrong_kind_is_rejected_when_parsing() {
242        let event = EventBuilder::text_note("nope")
243            .sign_with_keys(&keys())
244            .unwrap();
245        assert!(matches!(
246            ApplicationData::from_event(&event),
247            Err(ApplicationDataError::WrongKind(_))
248        ));
249    }
250
251    #[test]
252    fn empty_identifier_is_allowed_per_spec() {
253        // The spec says "any other arbitrary string" — including empty.
254        let data = ApplicationData::new("");
255        let event = EventBuilder::application_data(&data)
256            .sign_with_keys(&keys())
257            .unwrap();
258        let parsed = ApplicationData::from_event(&event).unwrap();
259        assert_eq!(parsed.identifier, "");
260    }
261}