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:
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_atUnsignedEvent: addspubkeyEvent/VerifiedEvent: complete withid,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 compareEvent::verify_signature(): Schnorr viasecp256k1::verify_schnorrEvent::verify(): both, returningResult<(), 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 aEventIdnewtype (rust-nostr uses newtype; keeps the API clean and enforces length). Start simple —Stringof hex or[u8; 32].pubkey: reuse thePublicKeyalready 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: aSignaturetype or[u8; 64]or hex string.
Unsigned vs signed. Two approaches:
- Separate
UnsignedEventstruct (noid, nosig), with a method to compute the id and convert into a signedEvent. Clean but verbose. - 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>>.