# 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` 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` with chainable operations: `fromKind(1).content("hello").stamp(signer).sign(signer)`. Three lifecycle states: `EventTemplate` → `UnsignedEvent` (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`: ```typescript 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` — 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` 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`), 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>` — 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` 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: ```rust 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>`.