Add expiration chapter

This commit is contained in:
Jon Staab
2026-05-20 11:07:12 -07:00
parent 8773017198
commit f0d49c6fb8
5 changed files with 709 additions and 4 deletions
+199
View File
@@ -0,0 +1,199 @@
# 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
```rust {file=coracle-lib/src/lib.rs}
pub mod expiration;
```
```rust {file=coracle-lib/src/expiration.rs}
//! 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. Method versions on
//! [`HashedEvent`] and [`Event`] delegate to them for a readable call
//! site. The tag itself is built with [`Tag::expiration`] from the
//! tags module.
use crate::events::{Event, HashedEvent};
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.
```rust {file=coracle-lib/src/expiration.rs}
/// 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.
```rust {file=coracle-lib/src/expiration.rs}
/// 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`.
```rust {file=coracle-lib/src/expiration.rs}
/// 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:
```rust {file=coracle-lib/src/tags.rs}
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
Both `HashedEvent` and `Event` carry a `Tags` field, and callers who
have an event in hand almost always want to ask questions of its tags
without a separate import. The method impls delegate to the free
functions verbatim.
```rust {file=coracle-lib/src/expiration.rs}
impl HashedEvent {
/// Return the expiration timestamp of this event, if any.
pub fn get_expiration(&self) -> Option<u64> {
get_expiration(&self.tags)
}
/// Whether this event has expired relative to the given timestamp.
pub fn is_expired_at(&self, now: u64) -> bool {
is_expired_at(&self.tags, now)
}
/// Whether this event has expired relative to the system clock.
pub fn is_expired(&self) -> bool {
is_expired(&self.tags)
}
}
impl Event {
/// Return the expiration timestamp of this event, if any.
pub fn get_expiration(&self) -> Option<u64> {
get_expiration(&self.tags)
}
/// Whether this event has expired relative to the given timestamp.
pub fn is_expired_at(&self, now: u64) -> bool {
is_expired_at(&self.tags, now)
}
/// Whether this event has expired relative to the system clock.
pub fn is_expired(&self) -> bool {
is_expired(&self.tags)
}
}
```
## Usage patterns
**Building an expiring event.** Attach the tag at template
construction:
```rust
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:
```rust
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.