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

303 lines
14 KiB
Markdown

# 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:
`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<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:
```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<Vec<String>>`.