Skip to main content

nula_core/nips/
nip40.rs

1//! [NIP-40] Expiration Timestamp.
2//!
3//! NIP-40 lets the author of an event declare a deadline after which the
4//! event should be deleted by relays and ignored by clients. The deadline
5//! is encoded as a single tag:
6//!
7//! ```text
8//! ["expiration", "<unix_seconds>"]
9//! ```
10//!
11//! The crate parses, builds, and evaluates these tags through three small
12//! pieces of API:
13//!
14//! - [`parse_expiration`] reads an event's deadline (if any).
15//! - [`is_expired`] / [`is_expired_now`] tell you whether a deadline has
16//!   passed.
17//! - [`EventBuilder::expiration`] attaches a deadline when constructing an
18//!   event.
19//!
20//! [NIP-40]: https://github.com/nostr-protocol/nips/blob/master/40.md
21
22use thiserror::Error;
23
24use crate::event::{Event, EventBuilder, Tag, TagKind};
25use crate::types::{Timestamp, TimestampError};
26
27/// Wire name of the NIP-40 expiration tag (`expiration`).
28pub const EXPIRATION_TAG: &str = "expiration";
29
30/// Errors raised when reading an [`Event`]'s NIP-40 deadline.
31#[derive(Debug, Clone, Error)]
32#[non_exhaustive]
33pub enum ExpirationError {
34    /// The expiration tag had no value (i.e. only `["expiration"]`).
35    #[error("`expiration` tag is missing the timestamp value")]
36    MissingValue,
37    /// The expiration tag value was not a non-negative integer.
38    #[error("`expiration` tag value `{0}` is not a valid unix timestamp")]
39    InvalidTimestamp(String),
40}
41
42/// Read the NIP-40 deadline from `event`, if any.
43///
44/// Returns `Ok(None)` when no `expiration` tag is present and `Ok(Some(ts))`
45/// when the tag exists and is well formed.
46///
47/// # Errors
48///
49/// Returns [`ExpirationError`] if the tag exists but is malformed.
50pub fn parse_expiration(event: &Event) -> Result<Option<Timestamp>, ExpirationError> {
51    let kind = TagKind::from_wire(EXPIRATION_TAG);
52    let Some(tag) = event.tags.find_first(&kind) else {
53        return Ok(None);
54    };
55    let Some(value) = tag.values().get(1) else {
56        return Err(ExpirationError::MissingValue);
57    };
58    let secs: u64 = value
59        .parse()
60        .map_err(|_| ExpirationError::InvalidTimestamp(value.clone()))?;
61    Ok(Some(Timestamp::from_secs(secs)))
62}
63
64/// Whether `event`'s deadline (if any) has passed at `now`.
65///
66/// An event without an `expiration` tag is never considered expired
67/// (returns `Ok(false)`).
68///
69/// # Errors
70///
71/// Returns [`ExpirationError`] if the tag exists but is malformed.
72pub fn is_expired(event: &Event, now: Timestamp) -> Result<bool, ExpirationError> {
73    Ok(parse_expiration(event)?.is_some_and(|deadline| now >= deadline))
74}
75
76/// Like [`is_expired`] but reads the wall clock for `now`.
77///
78/// # Errors
79///
80/// Returns [`ExpirationError`] for a malformed tag, or
81/// [`TimestampError`] if the system clock cannot be read.
82pub fn is_expired_now(event: &Event) -> Result<bool, IsExpiredError> {
83    let now = Timestamp::now()?;
84    Ok(is_expired(event, now)?)
85}
86
87/// Composite error returned by [`is_expired_now`].
88#[derive(Debug, Error)]
89#[non_exhaustive]
90pub enum IsExpiredError {
91    /// The expiration tag was malformed.
92    #[error(transparent)]
93    Expiration(#[from] ExpirationError),
94    /// The wall clock could not be read.
95    #[error(transparent)]
96    Clock(#[from] TimestampError),
97}
98
99impl EventBuilder {
100    /// Attach a NIP-40 expiration deadline.
101    ///
102    /// Subsequent calls replace any earlier deadline so the resulting event
103    /// always carries at most one `expiration` tag.
104    #[must_use]
105    pub fn expiration(mut self, ts: Timestamp) -> Self {
106        let kind = TagKind::from_wire(EXPIRATION_TAG);
107        let tag = Tag::with(&kind, [ts.as_secs().to_string()]);
108        self.tags_mut().replace_or_push(&kind, tag);
109        self
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::Keys;
117
118    fn keys() -> Keys {
119        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
120    }
121
122    #[test]
123    fn missing_tag_returns_none() {
124        let event = EventBuilder::text_note("no-deadline")
125            .created_at(Timestamp::from_secs(1))
126            .sign_with_keys(&keys())
127            .unwrap();
128        assert_eq!(parse_expiration(&event).unwrap(), None);
129        assert!(!is_expired(&event, Timestamp::from_secs(u64::MAX)).unwrap());
130    }
131
132    #[test]
133    fn builder_attaches_expiration_tag() {
134        let deadline = Timestamp::from_secs(1_700_000_000);
135        let event = EventBuilder::text_note("deadline")
136            .created_at(Timestamp::from_secs(1))
137            .expiration(deadline)
138            .sign_with_keys(&keys())
139            .unwrap();
140        assert_eq!(parse_expiration(&event).unwrap(), Some(deadline));
141    }
142
143    #[test]
144    fn expiration_replaces_previous() {
145        let earlier = Timestamp::from_secs(100);
146        let later = Timestamp::from_secs(200);
147        let event = EventBuilder::text_note("replace")
148            .created_at(Timestamp::from_secs(1))
149            .expiration(earlier)
150            .expiration(later)
151            .sign_with_keys(&keys())
152            .unwrap();
153        assert_eq!(parse_expiration(&event).unwrap(), Some(later));
154        // Only one expiration tag should remain.
155        let count = event
156            .tags
157            .iter()
158            .filter(|t| t.kind() == TagKind::from_wire(EXPIRATION_TAG))
159            .count();
160        assert_eq!(count, 1);
161    }
162
163    #[test]
164    fn before_deadline_not_expired() {
165        let event = EventBuilder::text_note("future")
166            .created_at(Timestamp::from_secs(1))
167            .expiration(Timestamp::from_secs(2_000))
168            .sign_with_keys(&keys())
169            .unwrap();
170        assert!(!is_expired(&event, Timestamp::from_secs(1_999)).unwrap());
171    }
172
173    #[test]
174    fn at_or_after_deadline_is_expired() {
175        let event = EventBuilder::text_note("late")
176            .created_at(Timestamp::from_secs(1))
177            .expiration(Timestamp::from_secs(2_000))
178            .sign_with_keys(&keys())
179            .unwrap();
180        assert!(is_expired(&event, Timestamp::from_secs(2_000)).unwrap());
181        assert!(is_expired(&event, Timestamp::from_secs(2_001)).unwrap());
182    }
183
184    #[test]
185    fn malformed_value_is_reported() {
186        let event = EventBuilder::text_note("oops")
187            .created_at(Timestamp::from_secs(1))
188            .tag(Tag::new(["expiration", "soon"]).unwrap())
189            .sign_with_keys(&keys())
190            .unwrap();
191        let err = parse_expiration(&event).unwrap_err();
192        assert!(matches!(err, ExpirationError::InvalidTimestamp(_)));
193    }
194
195    #[test]
196    fn missing_value_is_reported() {
197        let event = EventBuilder::text_note("oops")
198            .created_at(Timestamp::from_secs(1))
199            .tag(Tag::new(["expiration"]).unwrap())
200            .sign_with_keys(&keys())
201            .unwrap();
202        let err = parse_expiration(&event).unwrap_err();
203        assert!(matches!(err, ExpirationError::MissingValue));
204    }
205}