6.9 KiB
Expiring Events
Not every event is meant to last. A chat message, a transient status, a
time-limited invitation — these are events whose value decays to zero
at a known point in the future. NIP-40 gives them a way to say so: an
expiration tag carrying a Unix timestamp in seconds. After that
timestamp, the event is considered no longer valid.
The tag is a behavior tag — orthogonal to the event's kind. A kind 1 note with an expiration is still a note; the expiration is a request that relays stop serving it and clients stop displaying it once the time is up. The distinction matters. Nostr clients and relays already agreed, per kind, on what the payload means. The behavior tag layer lets authors add a separate instruction about what the network should do with the event, without reopening the kind's semantics.
Expiration is a cooperative hint, not a cryptographic guarantee. Anyone who has a copy of the event keeps that copy; the signature remains valid forever. What the tag buys is a shared convention: well-behaved relays will refuse to serve the event after the timestamp, well-behaved clients will hide it, and storage layers that care about disk space can sweep it. The library's job at this level is only to parse the tag and answer "is this event expired?" — deciding what to do about that answer belongs to code further up the stack.
The module
pub mod expiration;
//! NIP-40 expiring events: reading the `expiration` tag and checking
//! whether an event has expired.
//!
//! The free functions in this module operate on [`Tags`] so they can be
//! used wherever a tag slice is in hand. The [`EventExtensionExpiration`]
//! trait layers method-call sugar on top for any event type, reachable
//! through the crate prelude. The tag itself is built with
//! [`Tag::expiration`] from the tags module.
use crate::events::HasTags;
use crate::tags::Tags;
use crate::util::now;
Reading the tag
The shape on the wire is ["expiration", "<unix_seconds>"]. Parsing it
means finding the first tag with that name and interpreting its value
as a u64. Two ways to read this can go wrong — the tag may be absent,
or its value may not parse as a number — and both collapse to the same
answer: None. A malformed tag is indistinguishable from no tag at
all; neither tells us when the event should be considered expired, so
neither should produce a different downstream behavior.
/// Return the expiration timestamp from a tag set, if present.
///
/// Reads the first `expiration` tag and parses its value as a Unix
/// timestamp in seconds. Returns `None` if the tag is missing or its
/// value does not parse as a `u64` — treating "malformed" the same as
/// "absent" avoids forcing every caller to distinguish a case they
/// cannot usefully act on.
pub fn get_expiration(tags: &Tags) -> Option<u64> {
tags.value("expiration").and_then(|v| v.parse().ok())
}
A testable predicate
A predicate that reads the system clock is awkward to test. The
standard fix — take the clock as a parameter — costs nothing here
because the caller usually has a now value on hand anyway (a relay
checking a batch of incoming events picks a timestamp once and reuses
it, a UI rendering a timeline pins "now" at render time). The
two-function split keeps both audiences happy: callers who already
have a timestamp pass it in, and callers who just want to check
against wall-clock time call the shorter form that fills it in for
them.
The comparison is strict: expiration < now. An event whose
expiration equals the current time is still valid; it becomes expired
the moment now advances past it. "Expires at T" reads naturally as
"invalid after T," and that reading matches what every reference
implementation does.
/// Test whether the tags declare an expiration that has already
/// passed at the given `now` timestamp.
///
/// Returns `false` if no expiration tag is present. The comparison is
/// strict: an event with expiration equal to `now` is *not* yet
/// expired.
pub fn is_expired_at(tags: &Tags, now: u64) -> bool {
matches!(get_expiration(tags), Some(t) if t < now)
}
Against the system clock
The wall-clock form is a one-liner over is_expired_at, filling in the
timestamp from crate::util::now.
/// Test whether the tags declare an expiration that has already
/// passed, relative to the system clock.
///
/// For batch checks or testable callers, prefer [`is_expired_at`] and
/// pass an explicit timestamp.
pub fn is_expired(tags: &Tags) -> bool {
is_expired_at(tags, now())
}
Building the tag
We can add a small convenience method for building this tag:
impl Tag {
/// Build a NIP-40 `expiration` tag with the given Unix timestamp in seconds.
pub fn expiration(timestamp: u64) -> Tag {
Tag::new("expiration", [timestamp.to_string()])
}
}
Methods on event types
Rather than repeat these queries on each event struct, expiration exposes
them through an extension trait bounded on [HasTags]. The method bodies
live once, as defaults, and a blanket impl makes them available on every
type that can hand back its tags — HashedEvent, Event, and anything
added later. With coracle_lib::prelude::* in scope, event.is_expired()
reads the same whether the event is hashed or signed.
/// Expiration queries on any event that carries tags.
pub trait EventExtensionExpiration: HasTags {
/// Return the expiration timestamp of this event, if any.
fn get_expiration(&self) -> Option<u64> {
get_expiration(self.tags())
}
/// Whether this event has expired relative to the given timestamp.
fn is_expired_at(&self, now: u64) -> bool {
is_expired_at(self.tags(), now)
}
/// Whether this event has expired relative to the system clock.
fn is_expired(&self) -> bool {
is_expired(self.tags())
}
}
impl<T: HasTags> EventExtensionExpiration for T {}
pub use crate::expiration::EventExtensionExpiration;
Usage patterns
Building an expiring event. Attach the tag at template construction:
let event = EventContent::new()
.content("this link is good for an hour")
.tags(Tags::new().add_tag(Tag::expiration(now + 3600)))
.kind(1)
.stamp(now)
.own(pubkey);
Filtering out expired events. Relays and clients both might need to
remove expired events from a collection. Using is_expired_at avoids the
need to re-generate the current timestamp repeatedly:
let now = util::now();
incoming.retain(|e| !e.is_expired_at(now));
What's next
Expiration is one kind of behavior tag. The next chapter introduces
another — NIP-70's protected tag, which tells relays not to accept
an event from anyone but its author.