303 lines
14 KiB
Markdown
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>>`.
|