Files
coracle-rust/book/09-expiring-events.md
T
2026-05-20 14:27:26 -07:00

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.