Files
coracle-rust/book/research/events.md
T
2026-04-14 14:25:54 -07:00

14 KiB

Research: Events

Topic Summary

The events chapter covers the core nostr Event data structure: fields (id, pubkey, created_at, kind, tags, content, sig), JSON serialization, canonical form, event ID computation (sha256 of canonical JSON), unsigned vs signed event representations, and structural validation. Signing is addressed by adding a sign method to PrivateKey that signs an arbitrary string (not an event). The chapter includes an illustrative example of event signing using this primitive, but does not tangle a sign method on events themselves.

Philosophy

From ref/building-nostr/content/book.md and summary.md:

Events as referentially transparent facts. An event's ID is the hash of its content (NIP-01), making events content-addressable and immutable. If you have an event ID, you know the exact content forever. This is the foundation of nostr's break from server-centric architectures. Regular, non-replaceable events should be the default because replaceable events break referential transparency.

Permissionless validity through signatures. Events gain legitimacy through secp256k1 Schnorr signatures. Any event that is signed is valid simply by virtue of existing — validation is limited to hash and signature verification plus a few structural checks. The protocol explicitly rejects schema enforcement in favor of social/reputational filtering.

Minimal kernel. Events are the entire nostr substrate. All application semantics emerge from event kinds and tagged references. The protocol itself is "a very humble protocol — it is little more than an empty shell which users can then fill with content types that solve their use cases."

Four-part structure.

  • Cryptographic core: id, pubkey, sig
  • Metadata: created_at, kind
  • Structured data: tags (array of string arrays)
  • Human-readable: content

Content should hold human-readable payloads. Encoding JSON or encrypted data in content is considered an antipattern (though encrypted DMs necessarily do this).

Weak timestamps. created_at is a second-granularity Unix timestamp supplied unilaterally by the author. Ordering is delegated to the social layer; nostr refuses to solve distributed clock problems.

Fault-tolerant validation. "Liberal in what you accept" applied conservatively: tolerate malformed data but always verify signatures. Never repair data that contradicts conventions.

Reference Implementation Analysis

applesauce

Re-exports core types (NostrEvent, EventTemplate, UnsignedEvent, VerifiedEvent) from nostr-tools/pure rather than defining its own. Adds kind-narrowing wrappers KnownEvent<K> etc. for type safety. Delegates serialization, hashing, and signature verification entirely to nostr-tools.

The notable contribution is EventFactory (packages/core/src/factories/event.ts), a fluent builder extending Promise<T> with chainable operations: fromKind(1).content("hello").stamp(signer).sign(signer). Three lifecycle states: EventTemplateUnsignedEvent (has pubkey) → NostrEvent (signed).

Structural validation (isEvent) checks string/number types and 64-char hex lengths for id/pubkey but does NOT verify signatures. Signature verification is pluggable on the AsyncEventStore via a verifyEvent callback, cached after first verification via a symbol property. Attaches metadata via symbols to avoid polluting the event object.

ndk

Uses class-based design: NDKEvent extends EventEmitter with mutable public fields. Provides inheritance hierarchy for kind-specific subclasses (NDKArticle, NDKZap, etc.).

Canonical serialization in core/src/events/serializer.ts produces the NIP-01 JSON array [0, pubkey, created_at, kind, tags, content]. Hash computed via @noble/hashes sha256 plus hex encoding. Validation uses regex /^[a-f0-9]{64}$/ for pubkey format, and throws detailed errors with event snapshots on structural failure.

Signature verification supports two modes: synchronous (blocking, via @noble/curves schnorr) or Web-Worker offloaded for non-blocking verification in browsers. Results cached in a 1000-entry LRU with 60s TTL. Type-safe signed vs unsigned via discriminated unions (NDKSignedEvent, NDKUnsignedEvent) with guard functions.

toNostrEvent() finalizes by computing id hash before signing; sign() delegates to pluggable NDKSigner. Notable: in-place mutation is pervasive (setters on all fields), trading safety for ergonomics.

nostr-gadgets

Treats events as plain TS objects imported from @nostr/tools/pure; does not define its own Event type. Consumes already-finalized events — ID computation, signing, and verification are all delegated to nostr-tools.

Interesting only for its storage strategy: RedEventStore constructs JSON manually ('{"pubkey":"${event.pubkey}","id":"${event.id}",...}') to control field order and append custom metadata like seen_on. Validation is limited to structural bounds: created_at > 0xffffffff || kind > 0xffff. No signature verification at the storage layer — that's assumed to happen upstream at the relay/pool boundary.

Uses Symbol properties (isLocalSymbol, seenOnSymbol) to attach metadata without mutating events. No builder pattern — callers use finalizeEvent from nostr-tools directly.

nostr-tools

The de facto reference for TypeScript nostr. Defines the canonical NostrEvent type as a plain object (not a class). Uses a verifiedSymbol for type discrimination of verified events without wrapping them.

Three type variants via Pick<>:

  • EventTemplate: kind, tags, content, created_at
  • UnsignedEvent: adds pubkey
  • Event / VerifiedEvent: complete with id, sig

Canonical serialization in pure.ts:

function serializeEvent(evt: UnsignedEvent): string {
  return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
}

getEventHash() is sha256 over UTF-8 encoded canonical JSON, returned as hex. finalizeEvent(template, secretKey) bridges template → signed event, mutating the template in-place for efficiency.

Validation (validateEvent) checks field types, regex-validates pubkey format, and verifies tag shape. verifyEvent performs both ID recomputation (catches tampering) and Schnorr signature verification via @noble/curves/secp256k1. Result cached via verifiedSymbol.

Dual backend: pure.ts uses pure-JS @noble; wasm.ts provides a WASM alternative with identical API.

rust-nostr

The most relevant reference, being Rust. Defines three event representations:

Event (event/mod.rs): #[non_exhaustive] struct with public immutable fields. Implements Clone, Eq, Ord (by created_at desc, then id), Hash. Construction only via Event::new(), protecting against future additive changes.

UnsignedEvent (event/unsigned.rs): Same fields minus sig, with id as Option<EventId> — lazily computed on first access via interior mutability.

EventBorrow<'a> (event/borrow.rs): Zero-copy view for serialization and validation without allocating; holds &[u8; 32] for id/pubkey/sig.

Supporting types: EventId is a newtype around [u8; 32], serialized as hex; Kind is an enum with named variants plus Custom(u16); Tag wraps Vec<String> with lazy standardization parsing via OnceCell.

Serialization: Uses an EventIntermediate<'a> struct with Cow<'a, T> fields for zero-copy serialize and owned deserialize. Custom hex encoding for signatures via serialize_with. Silently ignores unknown JSON fields (lenient for relay compat).

ID computation: EventId::new() uses serde_json::json!() to build the canonical [0, pubkey, created_at, kind, tags, content] array, serializes to string, hashes with bitcoin_hashes::sha256::Hash, returns as 32-byte newtype.

Validation:

  • Event::verify_id(): recompute and compare
  • Event::verify_signature(): Schnorr via secp256k1::verify_schnorr
  • Event::verify(): both, returning Result<(), Error>

EventBuilder (event/builder.rs): fluent builder that produces UnsignedEvent via .build(pubkey). Includes POW mining (optional multi-threaded via Arc<AtomicBool>), self-tag filtering, tag deduplication, and custom timestamps. Signing via NostrSigner trait (async) or Keys directly (sync).

no_std support: Conditional core::cell::OnceCell vs std::sync::OnceLock.

Dependencies: secp256k1 0.29, serde/serde_json, bitcoin_hashes, faster-hex. Event module is self-contained aside from tight coupling to Tag/Kind submodules.

welshman

Data-oriented, composition-first design. Defines a layered type hierarchy:

EventTemplate → StampedEvent → OwnedEvent → HashedEvent → SignedEvent
     (content)    (+ created_at)   (+ pubkey)   (+ id)      (+ sig)

Each is a structural type. Construction is a pipeline of pure functions: stamp(event, created_at) → own(event, pubkey) → hash(event) → sign(event, secret). No builder class; composition via function calls.

Delegates crypto to nostr-tools and @noble/curves. getHash is a one-liner wrapping getEventHash. Signature verification upgrades from pure-JS to WASM asynchronously if available, caches results via the verifiedSymbol.

Adds TrustedEvent: an event accepted without verification (e.g., from canonical relay storage). Represents the distinction between "cryptographically verified" and "contextually trusted."

Structural type guards (isEventTemplate, isSignedEvent) check field types and formats.

Common Patterns

Canonical serialization. All implementations use NIP-01's JSON array form [0, pubkey, created_at, kind, tags, content]. Order matters (deterministic output).

Event ID = sha256(canonical JSON). Universal. Implementations differ only in which sha256 library they use.

Layered types for lifecycle states. Everyone distinguishes template / unsigned / signed, though the exact type names vary (welshman has the most granular hierarchy with five stages).

Delegation. Every TypeScript library delegates the hashing and signature primitives to @noble/curves + @noble/hashes (via nostr-tools or directly). Only rust-nostr and nostr-tools actually implement canonical serialization themselves.

Structural validation is cheap and separate from signature verification. Libraries uniformly distinguish "is this the right shape?" from "is the signature valid?" — and often verify signatures lazily, cached, or on-demand.

Tags are string[][]. Every implementation represents tags as arrays of string arrays. None impose a richer schema at the Event level.

Plain data over classes. TypeScript libraries mostly use plain objects (nostr-tools, nostr-gadgets, welshman, applesauce). NDK is the outlier with a class hierarchy. rust-nostr uses immutable structs with public fields.

Divergence: mutability. NDK mutates freely; nostr-tools mutates templates in finalizeEvent; welshman and applesauce build new objects. rust-nostr is strictly immutable after construction.

Divergence: builder vs function composition. rust-nostr and applesauce have fluent builders. nostr-tools, welshman, and nostr-gadgets expose plain functions.

Considerations for Our Implementation

Use serde. serde + serde_json is the obvious choice for a Rust implementation. The canonical form [0, pubkey, created_at, kind, tags, content] can be produced via serde_json::json! or a custom Serialize impl.

Event struct. A plain struct with public fields matches rust-nostr and the philosophy of "events are data." Fields: id, pubkey, created_at, kind, tags, content, sig.

Field types.

  • id: [u8; 32] or a EventId newtype (rust-nostr uses newtype; keeps the API clean and enforces length). Start simple — String of hex or [u8; 32].
  • pubkey: reuse the PublicKey already defined in the keys chapter.
  • created_at: u64 (Unix timestamp seconds).
  • kind: u16 (nostr kinds fit in 16 bits).
  • tags: Vec<Vec<String>> — simple and correct.
  • content: String.
  • sig: a Signature type or [u8; 64] or hex string.

Unsigned vs signed. Two approaches:

  1. Separate UnsignedEvent struct (no id, no sig), with a method to compute the id and convert into a signed Event. Clean but verbose.
  2. Single struct with Option<String> for id/sig. Mirrors welshman's approach but loses compile-time guarantees.

Given the teaching context, option 1 is clearer: it makes the lifecycle visible in the type system.

Canonical serialization via serde_json::json!. rust-nostr's approach is compact and readable:

let canonical = json!([0, pubkey, created_at, kind, tags, content]).to_string();
let id = sha256(canonical.as_bytes());

ID computation as a method on UnsignedEvent. fn id(&self) -> [u8; 32].

Signing primitive on PrivateKey. Per user's guidance: add PrivateKey::sign(&self, message: &[u8]) -> Signature (or &str), signing an arbitrary byte string via Schnorr. The chapter shows an example of combining unsigned.id() with private_key.sign(&id) to produce a complete Event, but this orchestration is illustrative prose, not tangled source.

Verification. Event::verify_id() recomputes and compares; Event::verify() also verifies the Schnorr signature via the existing PublicKey-based primitive (or we add PublicKey::verify(message, signature) as a companion to PrivateKey::sign).

Dependencies already in tree. From chapter 02 and 03, secp256k1 (or k256), sha2, and hex crates should already be available. Need to confirm and reuse. Add serde, serde_json if not yet in coracle-lib.

Structural validation. Keep minimal: lengths of id and sig, format of pubkey (already enforced if PublicKey is a typed wrapper), non-negative timestamp (enforced by u64).

Kinds chapter is separate. Don't enumerate kinds; just expose u16. A later chapter adds an enum or constants.

Tags chapter is separate. Don't build tag helpers; just Vec<Vec<String>>.