diff --git a/book/05-events.md b/book/05-events.md index d01eb2a..e8b40f3 100644 --- a/book/05-events.md +++ b/book/05-events.md @@ -162,10 +162,33 @@ impl EventContent { ### `StampedEvent` -Stamping adds the `created_at` timestamp. Callers usually pass -`SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()`, but the -library stays out of clock policy — the timestamp is whatever the caller says -it is. +Stamping adds the `created_at` timestamp. The library stays out of clock +policy — the timestamp is whatever the caller says it is — but in practice +"whatever the caller says" is almost always "right now," so we give that +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} /// A template with a `created_at` timestamp attached. diff --git a/book/09-expiring-events.md b/book/09-expiring-events.md new file mode 100644 index 0000000..290b85c --- /dev/null +++ b/book/09-expiring-events.md @@ -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", ""]`. 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 { + 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 { + 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 { + 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. diff --git a/book/plan/expiring-events.md b/book/plan/expiring-events.md new file mode 100644 index 0000000..9628d10 --- /dev/null +++ b/book/plan/expiring-events.md @@ -0,0 +1,137 @@ +# Plan: Expiring Events + +## Topic Summary + +NIP-40 defines an optional `expiration` tag: +`["expiration", ""]`. 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`. + 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; +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; + pub fn is_expired_at(&self, now: u64) -> bool; + pub fn is_expired(&self) -> bool; +} + +impl Event { + pub fn get_expiration(&self) -> Option; + 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. diff --git a/book/research/expiring-events.md b/book/research/expiring-events.md new file mode 100644 index 0000000..861afef --- /dev/null +++ b/book/research/expiring-events.md @@ -0,0 +1,182 @@ +# Research: Expiring Events + +## Topic Summary + +NIP-40 defines an optional `expiration` tag of the form +`["expiration", ""]`. 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` + 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` wrapper with accessor methods. Following that +style: + +- `get_expiration(tags: &Tags) -> Option` — 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. diff --git a/coracle-lib/tests/expiration.rs b/coracle-lib/tests/expiration.rs new file mode 100644 index 0000000..be9e374 --- /dev/null +++ b/coracle-lib/tests/expiration.rs @@ -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)); +}