Skip to main content

nula_core/nips/
nip09.rs

1//! [NIP-09] Event Deletion Request.
2//!
3//! NIP-09 lets an author request deletion of one or more of their own
4//! events by publishing a `kind: 5` event whose tags reference the
5//! targets:
6//!
7//! ```text
8//! ["e", "<event-id>"]                     // a regular event
9//! ["a", "<kind>:<author>:<identifier>"]   // a parameterized replaceable event
10//! ["k", "<kind>"]                          // optional hint
11//! ```
12//!
13//! The `content` is a free-form, human-readable reason (which may be
14//! empty). Relays SHOULD honour the request only when the deletion event
15//! and the targeted event share the same author.
16//!
17//! [`DeletionRequest`] models the request, serializes to / parses from a
18//! NIP-09 event, and exposes [`validate_target_authority`] for relays
19//! checking that a candidate target was authored by the deletion's
20//! signer.
21//!
22//! [NIP-09]: https://github.com/nostr-protocol/nips/blob/master/09.md
23
24use thiserror::Error;
25
26use crate::event::{
27    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
28    SingleLetterTag, Tag, TagKind,
29};
30use crate::key::PublicKey;
31
32/// Wire name of the NIP-09 kind hint tag (`k`).
33pub const KIND_TAG: &str = "k";
34
35/// A deletion request published as a `kind: 5` event.
36///
37/// Use [`DeletionRequest::new`] + the chainable `delete_*` / `with_*`
38/// methods to build a request, [`EventBuilder::deletion`] to turn it into
39/// a signable event, or [`DeletionRequest::from_event`] to parse one off
40/// the wire.
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
42pub struct DeletionRequest {
43    /// Regular events to delete (the `e` tag values).
44    pub event_ids: Vec<EventId>,
45    /// Parameterized replaceable events to delete (the `a` tag values).
46    pub coordinates: Vec<Coordinate>,
47    /// Kind hints (`k` tags) for relays that index by kind.
48    pub kinds: Vec<Kind>,
49    /// Free-form reason; an empty string means "no reason supplied".
50    pub reason: String,
51}
52
53impl DeletionRequest {
54    /// Construct an empty request.
55    #[must_use]
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Add a regular event id to delete.
61    #[must_use]
62    pub fn delete_event(mut self, id: EventId) -> Self {
63        self.event_ids.push(id);
64        self
65    }
66
67    /// Add several event ids.
68    #[must_use]
69    pub fn delete_events(mut self, ids: impl IntoIterator<Item = EventId>) -> Self {
70        self.event_ids.extend(ids);
71        self
72    }
73
74    /// Add a coordinate (parameterized replaceable event) to delete.
75    #[must_use]
76    pub fn delete_coordinate(mut self, coord: Coordinate) -> Self {
77        self.coordinates.push(coord);
78        self
79    }
80
81    /// Add a kind hint.
82    #[must_use]
83    pub fn hint_kind(mut self, kind: Kind) -> Self {
84        self.kinds.push(kind);
85        self
86    }
87
88    /// Set the human-readable reason.
89    #[must_use]
90    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
91        self.reason = reason.into();
92        self
93    }
94
95    /// Render the deletion request as the [`Tag`]s that go into a `kind: 5`
96    /// event.
97    #[must_use]
98    pub fn to_tags(&self) -> Vec<Tag> {
99        let mut tags =
100            Vec::with_capacity(self.event_ids.len() + self.coordinates.len() + self.kinds.len());
101        for id in &self.event_ids {
102            tags.push(Tag::e(*id));
103        }
104        for coord in &self.coordinates {
105            tags.push(Tag::a(coord));
106        }
107        for kind in &self.kinds {
108            tags.push(Tag::k(*kind));
109        }
110        tags
111    }
112
113    /// Parse a [`DeletionRequest`] from a `kind: 5` [`Event`].
114    ///
115    /// Tags whose head is not one of `e`/`a`/`k` are silently ignored
116    /// (forward-compat).
117    ///
118    /// # Errors
119    ///
120    /// Returns [`DeletionError::UnexpectedKind`] if the event's kind is not
121    /// `5`, plus the matching parse error if any of the recognised tags is
122    /// malformed.
123    pub fn from_event(event: &Event) -> Result<Self, DeletionError> {
124        if event.kind != Kind::EVENT_DELETION {
125            return Err(DeletionError::UnexpectedKind(event.kind.as_u16()));
126        }
127        let mut request = Self::new().with_reason(event.content.clone());
128        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
129        let a_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
130        let k_kind = TagKind::from_wire(KIND_TAG);
131        for tag in &event.tags {
132            let head = tag.kind();
133            if head == e_kind {
134                let value = tag
135                    .values()
136                    .get(1)
137                    .ok_or(DeletionError::MissingTagValue { tag: "e" })?;
138                request.event_ids.push(value.parse::<EventId>()?);
139            } else if head == a_kind {
140                let value = tag
141                    .values()
142                    .get(1)
143                    .ok_or(DeletionError::MissingTagValue { tag: "a" })?;
144                request.coordinates.push(value.parse::<Coordinate>()?);
145            } else if head == k_kind {
146                let value = tag
147                    .values()
148                    .get(1)
149                    .ok_or(DeletionError::MissingTagValue { tag: "k" })?;
150                let raw: u16 = value
151                    .parse()
152                    .map_err(|_| DeletionError::InvalidKindHint(value.clone()))?;
153                request.kinds.push(Kind::from(raw));
154            }
155        }
156        Ok(request)
157    }
158}
159
160impl EventBuilder {
161    /// Build a `kind: 5` deletion event from the given [`DeletionRequest`].
162    #[must_use]
163    pub fn deletion(request: &DeletionRequest) -> Self {
164        Self::new(Kind::EVENT_DELETION, request.reason.clone()).tags(request.to_tags())
165    }
166}
167
168/// Errors raised when parsing or applying a NIP-09 deletion event.
169#[derive(Debug, Clone, Error)]
170#[non_exhaustive]
171pub enum DeletionError {
172    /// The event's kind was not `5`.
173    #[error("expected kind 5, got {0}")]
174    UnexpectedKind(u16),
175    /// A tag head was recognised but had no value.
176    #[error("`{tag}` tag is missing its value")]
177    MissingTagValue {
178        /// Wire name of the offending tag head.
179        tag: &'static str,
180    },
181    /// An `e` tag value did not parse as a 32-byte event id.
182    #[error(transparent)]
183    InvalidEventId(#[from] EventIdError),
184    /// An `a` tag value did not parse as a [`Coordinate`].
185    #[error(transparent)]
186    InvalidCoordinate(#[from] CoordinateError),
187    /// A `k` tag value did not parse as `u16`.
188    #[error("invalid `k` tag hint: `{0}`")]
189    InvalidKindHint(String),
190}
191
192/// Errors raised by [`validate_target_authority`].
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
194#[non_exhaustive]
195pub enum AuthorityError {
196    /// The deletion event was not authored by the target's author. The
197    /// relay MUST refuse to apply the request in this case.
198    #[error("deletion author does not match target author")]
199    AuthorMismatch,
200}
201
202/// Verify that `deletion`'s author matches `target`'s author.
203///
204/// Relays MUST refuse to honour a NIP-09 request whose signer is not the
205/// author of the targeted event.
206///
207/// # Errors
208///
209/// Returns [`AuthorityError::AuthorMismatch`] when the authors differ.
210pub fn validate_target_authority(
211    deletion: &Event,
212    target_author: &PublicKey,
213) -> Result<(), AuthorityError> {
214    if deletion.pubkey == *target_author {
215        Ok(())
216    } else {
217        Err(AuthorityError::AuthorMismatch)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::Keys;
225    use crate::types::Timestamp;
226
227    fn keys() -> Keys {
228        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
229    }
230
231    fn other_keys() -> Keys {
232        Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
233    }
234
235    #[test]
236    fn round_trip_simple() {
237        let id = EventId::from_byte_array([0xab; 32]);
238        let request = DeletionRequest::new().delete_event(id).with_reason("typo");
239
240        let deletion = EventBuilder::deletion(&request)
241            .created_at(Timestamp::from_secs(1))
242            .sign_with_keys(&keys())
243            .unwrap();
244        deletion.verify().unwrap();
245        assert_eq!(deletion.kind, Kind::EVENT_DELETION);
246        assert_eq!(deletion.content, "typo");
247
248        let parsed = DeletionRequest::from_event(&deletion).unwrap();
249        assert_eq!(parsed, request);
250    }
251
252    #[test]
253    fn round_trip_with_coordinate_and_kind_hint() {
254        let id = EventId::from_byte_array([0x01; 32]);
255        let coord = Coordinate::new(Kind::from(30_023_u16), *keys().public_key(), "long-form-1");
256        let request = DeletionRequest::new()
257            .delete_event(id)
258            .delete_coordinate(coord)
259            .hint_kind(Kind::from(30_023_u16))
260            .with_reason("retract draft");
261
262        let deletion = EventBuilder::deletion(&request)
263            .created_at(Timestamp::from_secs(2))
264            .sign_with_keys(&keys())
265            .unwrap();
266        let parsed = DeletionRequest::from_event(&deletion).unwrap();
267        assert_eq!(parsed, request);
268    }
269
270    #[test]
271    fn empty_request_round_trips() {
272        let request = DeletionRequest::new();
273        let deletion = EventBuilder::deletion(&request)
274            .created_at(Timestamp::from_secs(3))
275            .sign_with_keys(&keys())
276            .unwrap();
277        let parsed = DeletionRequest::from_event(&deletion).unwrap();
278        assert_eq!(parsed, request);
279    }
280
281    #[test]
282    fn rejects_wrong_kind() {
283        let event = EventBuilder::text_note("not a deletion")
284            .created_at(Timestamp::from_secs(4))
285            .sign_with_keys(&keys())
286            .unwrap();
287        let err = DeletionRequest::from_event(&event).unwrap_err();
288        assert!(matches!(err, DeletionError::UnexpectedKind(1)));
289    }
290
291    #[test]
292    fn rejects_missing_e_value() {
293        let event = EventBuilder::new(Kind::EVENT_DELETION, "")
294            .created_at(Timestamp::from_secs(5))
295            .tag(Tag::new(["e"]).unwrap())
296            .sign_with_keys(&keys())
297            .unwrap();
298        let err = DeletionRequest::from_event(&event).unwrap_err();
299        assert!(matches!(err, DeletionError::MissingTagValue { tag: "e" }));
300    }
301
302    #[test]
303    fn validate_authority_accepts_matching_author() {
304        let request = DeletionRequest::new();
305        let deletion = EventBuilder::deletion(&request)
306            .created_at(Timestamp::from_secs(6))
307            .sign_with_keys(&keys())
308            .unwrap();
309        validate_target_authority(&deletion, keys().public_key()).unwrap();
310    }
311
312    #[test]
313    fn validate_authority_rejects_mismatching_author() {
314        let request = DeletionRequest::new();
315        let deletion = EventBuilder::deletion(&request)
316            .created_at(Timestamp::from_secs(7))
317            .sign_with_keys(&keys())
318            .unwrap();
319        let err = validate_target_authority(&deletion, other_keys().public_key()).unwrap_err();
320        assert_eq!(err, AuthorityError::AuthorMismatch);
321    }
322}