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
+27 -4
View File
@@ -162,10 +162,33 @@ impl EventContent {
### `StampedEvent` ### `StampedEvent`
Stamping adds the `created_at` timestamp. Callers usually pass Stamping adds the `created_at` timestamp. The library stays out of clock
`SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()`, but the policy — the timestamp is whatever the caller says it is — but in practice
library stays out of clock policy — the timestamp is whatever the caller says "whatever the caller says" is almost always "right now," so we give that
it is. common case a name. The `now` helper lives in a `util` module, the home for
the small, stateless helpers that don't belong to any one type.
```rust {file=coracle-lib/src/lib.rs}
pub mod util;
```
```rust {file=coracle-lib/src/util.rs}
//! Small stateless helpers shared across the library.
use std::time::{SystemTime, UNIX_EPOCH};
/// The current time as a Unix timestamp in seconds.
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
```
Stamping a template with the current time then reads as
`template.stamp(now())`. `stamp` itself takes a plain `u64`, leaving the
choice of clock to the caller.
```rust {file=coracle-lib/src/events.rs} ```rust {file=coracle-lib/src/events.rs}
/// A template with a `created_at` timestamp attached. /// A template with a `created_at` timestamp attached.
+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.
+137
View File
@@ -0,0 +1,137 @@
# Plan: Expiring Events
## Topic Summary
NIP-40 defines an optional `expiration` tag:
`["expiration", "<unix_timestamp_seconds>"]`. It marks an event as no
longer valid after the given time. Relays SHOULD stop serving expired
events and MAY drop them; clients SHOULD hide them. Expiration is a
cooperative hint, not a cryptographic guarantee.
This chapter adds tag-layer support: a parser, a predicate, a
constructor, and the usual method facades on `HashedEvent` and `Event`.
Enforcement (storage sweeps, UI hiding) is out of scope.
## Chapter Outline
1. **Framing** — expiration as a behavior tag; why it lives on `Tags`
rather than on kind; cooperative nature of the feature.
2. **The module declaration**`pub mod expiration;` in `lib.rs` plus
the module header.
3. **Reading the tag**`get_expiration(&Tags) -> Option<u64>`.
Explain missing-vs-malformed both collapsing to `None`.
4. **Testable predicate**`is_expired_at(&Tags, now: u64) -> bool`.
Explain the strict-`<` comparison.
5. **System-clock predicate**`is_expired(&Tags) -> bool`, plus the
private `now_seconds()` helper.
6. **Constructor**`Tag::expiration(ts: u64) -> Tag`. Explain why we
bother: a chapter's worth of call sites read better with a named
constructor than with `Tag::new("expiration", [ts.to_string()])`.
7. **Methods on event types**`get_expiration`, `is_expired`,
`is_expired_at` on both `HashedEvent` and `Event`.
8. **Usage patterns** — the expected call sites (a relay filter, a UI
check, a builder attaching a tag), very briefly.
9. **What's next** — bridge to protected events (chapter 10).
## API Design
### Module `coracle-lib/src/expiration.rs`
```rust
pub fn get_expiration(tags: &Tags) -> Option<u64>;
pub fn is_expired_at(tags: &Tags, now: u64) -> bool;
pub fn is_expired(tags: &Tags) -> bool;
impl HashedEvent {
pub fn get_expiration(&self) -> Option<u64>;
pub fn is_expired_at(&self, now: u64) -> bool;
pub fn is_expired(&self) -> bool;
}
impl Event {
pub fn get_expiration(&self) -> Option<u64>;
pub fn is_expired_at(&self, now: u64) -> bool;
pub fn is_expired(&self) -> bool;
}
```
### Tag constructor in `coracle-lib/src/tags.rs`
```rust
impl Tag {
pub fn expiration(timestamp: u64) -> Tag;
}
```
Rationale for placement: the constructor lives with `Tag` (not in
`expiration.rs`) because it belongs to the tag-construction surface,
which callers already import when they build tags. This matches how the
nonce tag is handled — built inline at its mining site — but gives
`expiration` a nicer call site because building this tag is common.
## Code Organization
- **`coracle-lib/src/expiration.rs`** — new module for the free
functions and the method impls.
- **`coracle-lib/src/lib.rs`** — add `pub mod expiration;`.
- **`coracle-lib/src/tags.rs`** — add the `Tag::expiration` constructor
inside an existing `impl Tag` block, tangled from the expiration
chapter (code blocks targeting the same file concatenate; the new
block adds another `impl Tag` block appended at the end, which is
valid Rust).
- **`coracle-lib/tests/expiration.rs`** — integration test (hand-
written, not tangled).
## Dependencies
None. `std::time::{SystemTime, UNIX_EPOCH}` is already in std. No new
crate dependencies, no new workspace entries.
## Narrative Notes
- Lead with the behavior-tag framing. A reader who has just finished
proof of work will recognize the pattern: a tag that *requests*
downstream cooperation. Expiration is the cleaner example.
- Make the "hint, not guarantee" point explicitly. The chapter is not
asserting that expired events are gone — it's giving callers the
primitive they need to act on the hint.
- Strict-`<` deserves a sentence. An event whose expiration equals
`now` is still valid; it becomes expired the moment `now` advances.
- The `get_expiration` / `is_expired` split mirrors the pattern from
`get_pow` in the previous chapter: a free function that takes tags,
plus method sugar on the event types. Call this out briefly — the
consistency is the point.
- Don't over-design `is_expired`. The `SystemTime::now()` fallback to
`0` on a clock error means an unreachable case maps to "never
expired," which is the safer default than "always expired".
- Keep the constructor section short. It's one line of code; the
narrative justifies adding it at all.
## Design Decisions
- **No `Timestamp` newtype.** `created_at` is `u64` everywhere; this
stays consistent.
- **No clock injection.** The testable `is_expired_at(now)` is enough;
a `Clock` trait would be ceremony in a library this size.
- **Strict `<` not `<=`.** Matches rust-nostr, welshman, applesauce.
"Expires at T" reads as "invalid after T".
- **Free functions take `&Tags` only.** The predicate doesn't need the
event id or pubkey. Keeping it at the `Tags` level makes it reusable
for any caller holding a tag slice (e.g., a relay streaming raw JSON
can check without reconstructing an `Event`).
- **No background sweeper, no storage integration.** That belongs in
a later storage chapter, if at all. This chapter is the parse layer.
- **Tag constructor in `tags.rs`.** Discoverable next to
`Tag::new`; avoids an import from `expiration` just to build a tag.
- **No `set_expiration(ts)` helper on event builders.** Callers can
write `.tags(tags.add_tag(Tag::expiration(ts)))` or the equivalent;
the builder surface is already chainable enough.
## Open Questions
- Whether to document the interaction with NIP-09 (deletion requests)
in this chapter. Keep it out — deletion is its own chapter's problem,
and entangling them here dilutes the focus.
- Whether to add a convenience `is_expired_now` on `Tags` directly.
Skip it; the free function is already named clearly, and adding
methods on `Tags` blurs the "tags are a generic container" line.
+182
View File
@@ -0,0 +1,182 @@
# Research: Expiring Events
## Topic Summary
NIP-40 defines an optional `expiration` tag of the form
`["expiration", "<unix_timestamp_seconds>"]`. When present, it marks the
event as no longer valid after the given time. Relays SHOULD stop
serving expired events and MAY drop them from storage. Clients SHOULD
not display expired events. Expiration is a hint, not a cryptographic
guarantee — anyone who already has a copy can keep it.
This chapter should cover:
- Reading the `expiration` tag from an event's tags
- A predicate `is_expired` (and a testable `is_expired_at(now)` variant)
- Constructing an expiration tag
- Attaching free-function accessors to `HashedEvent` and `Event`
- Handling missing and malformed tags (treated as never-expires)
Mining, storage filtering, and relay-side enforcement are out of scope —
the chapter stays at the tag-parsing layer.
## Philosophy
From `ref/building-nostr`:
- The `expiration` tag is a **behavior tag** — orthogonal to kind,
applicable to any event. It requests special handling by relays
rather than encoding meaning in the payload.
- Expiration is a *superset* of ephemeral-kind (2XXXX) functionality.
Putting retention policy in a tag frees it from the kind range.
- Expiration, like deletion (kind 5), is a **request, not a guarantee**:
"once it's published, there's no way to un-sign or un-publish".
Clients and relays cooperate voluntarily.
- Different content has different natural lifespans. Expiration is a
design choice reflecting use case — chat messages can expire; wiki
entries shouldn't.
The framing matters for the chapter: we are not building a deletion
mechanism. We are parsing an opt-in hint that downstream layers
(storage, UI, relay policy) may act on.
## Reference Implementation Analysis
### applesauce
- `getExpirationTimestamp(event)` reads the first `expiration` tag and
parses its value as a number; caches the parsed value via a Symbol.
- `isExpired(event)` compares the cached timestamp to `unixNow()`
(`Math.round(Date.now() / 1000)`). Missing tag → returns `false`.
- `setExpirationTimestamp(n)` is an event-builder operation that
replaces any existing expiration tag with `["expiration", n.toString()]`.
- `ExpirationManager` is an injectable storage-layer component that
tracks non-expired events and emits an `expired$` observable; an
`EventStore` filters expired events out of queries unless
`keepExpired: true`.
- Time is wall-clock only (`Date.now()`).
### ndk
- Minimal. Expiration is handled at the model level (e.g., a
`P2POrderEvent` class has `expiration` getter/setter); no
framework-wide `isExpired()` method.
- No storage-layer filtering of expired events.
### nostr-gadgets
- Nothing. Zero references to `expiration` or NIP-40.
### nostrlib (Go)
- `nip40.GetExpiration(tags) Timestamp` returns -1 on missing/malformed
tag. Plain int64 parse.
- `khatru/expiration.go` is a separate opt-in relay-side manager:
min-heap of expiring events, periodic sweep (default 1 hour), deletes
on expiration. Not used at query time — strictly a background cleaner.
- Uses `nostr.Now()` = `time.Now().Unix()`; no injection.
### nostr-tools
- `getExpiration(event)``Date` object (from unix seconds × 1000).
- `isEventExpired(event)` compares via `Date.now()`; missing tag → false.
- Bonus: `waitForExpire(event)` / `onExpire(event, cb)` schedule a
`setTimeout` to fire when the event expires — async sugar, not
essential to the core API.
- No tag constructor.
### rust-nostr
The most structured implementation:
- Tag modeled as `TagStandard::Expiration(Timestamp)` variant and
`TagKind::Expiration` kind.
- Constructor: `Tag::expiration(timestamp: Timestamp) -> Tag`.
- Query: `Tags::expiration() -> Option<&Timestamp>`.
- Predicates on `Event`:
- `is_expired_at(&self, now: &Timestamp) -> bool` — compares strictly
with `<` (expiration equal to now is not yet expired).
- `is_expired(&self) -> bool` (std only) — delegates to `now()`.
- `Timestamp` is a `u64` newtype in seconds. `Timestamp::now()` uses
`SystemTime` with an unwrap-to-default fallback; no clock trait.
- Storage feature flag `event_expiration: bool` in the database trait;
currently all backends set it to `false`, so filtering is
application-level.
### welshman
- `Repository` maintains a separate `expired: Map<eventId, timestamp>`
populated at insert time (parses `expiration` tag, skips NaN).
- `isExpired(event)` looks up in the map; strict `<` comparison.
- No builder for the tag; no automatic filtering in query paths.
## Common Patterns
- **Graceful absence.** Every implementation treats a missing
expiration tag as "never expires". None returns an error on absent
tags; most surface `Option` / `null` / a sentinel.
- **Graceful malformed.** Most implementations silently ignore tags
whose value doesn't parse. None treats a malformed tag as expired.
- **Strict `<` comparison.** rust-nostr, welshman, and applesauce all
use strict-less-than: an event whose expiration equals `now` is still
valid. This is the natural reading of "expires at time T" (the event
is invalid *after* T).
- **Wall-clock time.** No implementation injects a clock. Time comes
from `SystemTime::now()` / `Date.now()` / `time.Now()`. Testable
variants (passing `now` explicitly) are common.
- **Separation of parse from enforcement.** Parsing the tag is cheap
and always available. Doing something about it (removing expired
events from storage, hiding them in UI, sweeping a relay database)
is a separate concern layered on top.
- **Minimal tag constructor.** Rust-nostr has one; most TS libraries
expect the caller to build the tag by hand. Adding one is trivial
and makes call sites readable.
## Considerations for Our Implementation
**Scope.** This crate is `coracle-lib`, which owns nostr types and
stateless utilities. The chapter stays at that layer: tag accessor,
predicate, tag constructor. Storage sweeping and UI hiding belong in
downstream crates (`coracle-storage`, future application code).
**API shape, closely tracking rust-nostr but flatter.** The library has
deliberately avoided a `TagStandard` enum in earlier chapters — `Tag`
is a thin `Vec<String>` wrapper with accessor methods. Following that
style:
- `get_expiration(tags: &Tags) -> Option<u64>` — free function.
- `is_expired_at(tags: &Tags, now: u64) -> bool`.
- `is_expired(tags: &Tags) -> bool` — uses `SystemTime::now()`.
- Methods `get_expiration / is_expired / is_expired_at` on both
`HashedEvent` and `Event`, delegating to the free functions.
- A tag constructor. Two plausible shapes:
- `Tag::expiration(ts: u64) -> Tag` — parallels `Tag::new` but
specialized.
- Just document that callers can write `Tag::new("expiration",
[ts.to_string()])`.
The existing `pow.rs` module builds the nonce tag inline via
`Tag::new`, so there is a precedent for the inline form. But an
expiration-specific constructor is small and keeps call sites more
readable, and there's nothing nonstandard to hide. Lean toward
providing it.
**Type for timestamps.** The rest of the library uses plain `u64` for
`created_at`. Consistency argues for `u64` here too; no new
`Timestamp` newtype.
**Comparison semantics.** Use strict `<`. Document it in the rustdoc.
**Graceful parsing.** Missing tag or unparseable value → `None` /
`false`. No errors surfaced to callers.
**Time source.** Use `SystemTime::now().duration_since(UNIX_EPOCH)`
directly in `is_expired`. Keep `is_expired_at` available as the
testable primitive. No clock trait — it would cost more than it buys
in a library this size.
**Dependencies.** None beyond what's already pulled in (`std::time`).
**Narrative.** The chapter should build on the proof-of-work chapter's
structure — free functions plus methods on event types — and lean on
the philosophy framing: expiration is a **behavior tag**, a request,
not a cryptographic property of the event. That framing prepares the
reader for later chapters where storage and relay layers act on it.
+164
View File
@@ -0,0 +1,164 @@
use coracle_lib::events::{EventContent, HashedEvent};
use coracle_lib::expiration::{get_expiration, is_expired, is_expired_at};
use coracle_lib::keys::SecretKey;
use coracle_lib::tags::{Tag, Tags};
fn fixed_secret() -> SecretKey {
let bytes: [u8; 32] = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26, 27, 28, 29, 30, 31, 32,
];
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
}
fn build_hashed(tags: Tags) -> HashedEvent {
EventContent::new()
.content("hi")
.tags(tags)
.kind(1)
.stamp(1_700_000_000)
.own(fixed_secret().public_key())
.hash()
}
// --- Tag::expiration constructor ---
#[test]
fn tag_constructor_shape() {
let t = Tag::expiration(1_700_000_000);
assert_eq!(t.name(), "expiration");
assert_eq!(t.value(), "1700000000");
assert_eq!(t.len(), 2);
}
// --- get_expiration ---
#[test]
fn get_expiration_returns_none_when_tag_absent() {
let tags = Tags::new();
assert_eq!(get_expiration(&tags), None);
}
#[test]
fn get_expiration_parses_numeric_value() {
let tags = Tags::from(vec![Tag::expiration(1_700_000_000)]);
assert_eq!(get_expiration(&tags), Some(1_700_000_000));
}
#[test]
fn get_expiration_returns_none_when_malformed() {
let tags = Tags::from(vec![Tag::new("expiration", ["not-a-number"])]);
assert_eq!(get_expiration(&tags), None);
}
#[test]
fn get_expiration_reads_first_matching_tag() {
let tags = Tags::from(vec![
Tag::new("t", ["nostr"]),
Tag::expiration(100),
Tag::expiration(200),
]);
assert_eq!(get_expiration(&tags), Some(100));
}
// --- is_expired_at ---
#[test]
fn is_expired_at_false_when_tag_absent() {
let tags = Tags::new();
assert!(!is_expired_at(&tags, u64::MAX));
}
#[test]
fn is_expired_at_false_when_now_before_expiration() {
let tags = Tags::from(vec![Tag::expiration(100)]);
assert!(!is_expired_at(&tags, 99));
}
#[test]
fn is_expired_at_false_when_now_equals_expiration() {
// Strict `<`: equal is not yet expired.
let tags = Tags::from(vec![Tag::expiration(100)]);
assert!(!is_expired_at(&tags, 100));
}
#[test]
fn is_expired_at_true_when_now_past_expiration() {
let tags = Tags::from(vec![Tag::expiration(100)]);
assert!(is_expired_at(&tags, 101));
}
#[test]
fn is_expired_at_false_when_malformed() {
let tags = Tags::from(vec![Tag::new("expiration", ["nope"])]);
assert!(!is_expired_at(&tags, u64::MAX));
}
// --- is_expired (system clock) ---
#[test]
fn is_expired_true_for_unix_epoch_tag() {
// Unless the system clock is wildly wrong, `now` is far past 1.
let tags = Tags::from(vec![Tag::expiration(1)]);
assert!(is_expired(&tags));
}
#[test]
fn is_expired_false_for_far_future_tag() {
let tags = Tags::from(vec![Tag::expiration(u64::MAX)]);
assert!(!is_expired(&tags));
}
#[test]
fn is_expired_false_when_tag_absent() {
let tags = Tags::new();
assert!(!is_expired(&tags));
}
// --- HashedEvent method facades ---
#[test]
fn hashed_event_reads_expiration() {
let tags = Tags::from(vec![Tag::expiration(1_700_000_500)]);
let event = build_hashed(tags);
assert_eq!(event.get_expiration(), Some(1_700_000_500));
assert!(!event.is_expired_at(1_700_000_500));
assert!(event.is_expired_at(1_700_000_501));
}
#[test]
fn hashed_event_no_expiration_without_tag() {
let event = build_hashed(Tags::new());
assert_eq!(event.get_expiration(), None);
assert!(!event.is_expired_at(u64::MAX));
assert!(!event.is_expired());
}
// --- Event method facades ---
#[test]
fn signed_event_reads_expiration() {
let sk = fixed_secret();
let tags = Tags::from(vec![Tag::expiration(1_700_000_500)]);
let hashed = build_hashed(tags);
let sig = sk.sign(&hashed.id);
let signed = hashed.sign(sig);
assert_eq!(signed.get_expiration(), Some(1_700_000_500));
assert!(signed.is_expired_at(1_700_000_501));
assert!(!signed.is_expired_at(1_700_000_499));
}
#[test]
fn expiration_tag_survives_hashing_and_signing() {
// The tag is part of the canonical hash input, so it has to
// round-trip through `hash()` and `sign()` intact.
let sk = fixed_secret();
let ts = 1_700_000_500;
let hashed = build_hashed(Tags::from(vec![Tag::expiration(ts)]));
let sig = sk.sign(&hashed.id);
let signed = hashed.sign(sig);
assert!(signed.verify_id());
assert_eq!(signed.get_expiration(), Some(ts));
}