Add events chapter

This commit is contained in:
Jon Staab
2026-04-14 14:25:54 -07:00
parent 7c6909a791
commit 2553cff300
6 changed files with 1144 additions and 206 deletions
+449 -165
View File
@@ -1,202 +1,486 @@
# Events
Everything in nostr is an **event**. A short note, a profile update, a reaction, a relay list —
they're all events. An event is a JSON object signed by its author's cryptographic key. Once
signed, it cannot be altered without invalidating the signature, which means any relay or client
can verify that an event is authentic without trusting anyone.
Almost everything else in this book is built out of events. Profiles, follow
lists, direct messages, zap receipts, emoji reactions, long-form articles —
they are all the same data type with different values in a handful of fields.
If you understand events, you understand most of what a nostr library does.
## The event structure
An event is a JSON object with seven fields. What makes it interesting is
that the `id` field is a hash of the other fields and the `sig` field is a
Schnorr signature over that id. Anyone can verify, offline, that a given
event was produced by the holder of a particular secret key and has not been
altered since. There is no database row to update, no server to consult. The
event either hashes to its claimed id and verifies under its claimed pubkey,
or it doesn't — and that property, referential transparency in the functional
sense, is why nostr can work at all.
Here's what a nostr event looks like as JSON:
This chapter builds the Rust types that represent events and the two
primitives that make them go: computing the id and verifying the signature.
Construction is layered across six structs so that each stage of an event's
life is a distinct type; you can't sign something that hasn't been hashed,
and you can't hash something that doesn't know its author.
## The module
```rust {file=coracle-lib/src/lib.rs}
pub mod events;
```
```rust {file=coracle-lib/src/events.rs}
//! The nostr `Event` type, built up through a layered hierarchy of structs
//! that each add a single field: [`EventContent`], [`EventTemplate`],
//! [`StampedEvent`], [`OwnedEvent`], [`HashedEvent`], and [`Event`].
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::{Digest, Sha256};
use std::fmt;
use crate::keys::PublicKey;
```
## Errors
```rust {file=coracle-lib/src/events.rs}
/// Errors that can occur when parsing or verifying an event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventError {
/// The recomputed id did not match the event's `id` field.
InvalidId,
/// The Schnorr signature did not verify against the event's pubkey and id.
InvalidSignature,
}
impl fmt::Display for EventError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EventError::InvalidId => write!(f, "event id does not match its contents"),
EventError::InvalidSignature => write!(f, "event signature failed to verify"),
}
}
}
impl std::error::Error for EventError {}
```
## The wire format
A signed event on the wire looks like this:
```json
{
"id": "4376c65d2f232afbe9b882a35baa4f6fe8bce184396e157b1a35b3f01b3e285c",
"id": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65",
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"created_at": 1673347337,
"kind": 1,
"tags": [
["e", "3da979448d9ba263864c4d6f14984c423a3838364ec255f03c7904b1ae77f206"],
["p", "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce"]
],
"content": "Hello, nostr!",
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd..."
"tags": [["t", "nostr"]],
"content": "hello nostr",
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd0092619a2c8c1221e581946e0191f2af505dfdf8657a414dbca329186f009262"
}
```
Let's define this in Rust. We'll start with the module declaration:
`id`, `pubkey`, and `sig` are hex strings of fixed length. `created_at` is a
Unix timestamp in seconds. `kind` is a 16-bit integer. `tags` is a list of
lists of strings. `content` is a string whose meaning depends on the kind.
```rust {file=coracle-lib/src/lib.rs}
pub mod event;
```
## Building up an event
And now the `Event` struct itself. Each field maps directly to the JSON above:
Rather than one `Event` struct that may or may not have its id and signature
filled in, we define six structs, each of which adds exactly one field to the
previous. A value's type tells you which steps it has been through, and a
`.foo()` method takes you from one stage to the next.
```rust {file=coracle-lib/src/event.rs}
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
### `EventContent`
/// A nostr event the fundamental data type of the protocol.
///
/// Every interaction on nostr is represented as a signed event. The `id` is a
/// SHA-256 hash of the canonical serialization, and the `sig` is a Schnorr
/// signature over that hash using the author's private key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// The event ID: a 32-byte hex-encoded SHA-256 hash of the canonical serialization.
pub id: String,
/// The author's public key: a 32-byte hex-encoded secp256k1 public key (x-only).
pub pubkey: String,
/// Unix timestamp in seconds when the event was created.
pub created_at: u64,
/// The event kind determines how the event should be interpreted.
/// Kind 0 = metadata, kind 1 = short text note, kind 3 = contacts, etc.
pub kind: u16,
/// A list of tags. Each tag is an array of strings where the first element
/// identifies the tag type. Common tags: ["e", <event-id>], ["p", <pubkey>].
pub tags: Vec<Vec<String>>,
/// The event content. Interpretation depends on the kind.
The starting point is the payload the caller wants to publish: a
human-readable `content` string plus the structured `tags` that reference
other events, pubkeys, topics, and whatever else. Tags and content travel
together because tags are part of what the hash commits to — they are not
metadata in the "could be added later" sense.
```rust {file=coracle-lib/src/events.rs}
/// The content of an event: the human-readable body plus its tags.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventContent {
pub content: String,
/// A 64-byte hex-encoded Schnorr signature of the event ID.
pub sig: String,
pub tags: Vec<Vec<String>>,
}
```
## Computing the event ID
The event ID is not chosen by the author — it's *derived* from the event's content via a
deterministic process. This is what makes events tamper-proof: change any field and the ID
changes, which invalidates the signature.
The ID is the SHA-256 hash of a specific JSON serialization called the **canonical form**:
```json
[0, <pubkey>, <created_at>, <kind>, <tags>, <content>]
```
That leading `0` is a version number reserved for future use. The array is serialized as
compact JSON (no whitespace) and UTF-8 encoded before hashing.
```rust {file=coracle-lib/src/event.rs}
impl Event {
/// Serialize this event into the canonical form used for ID computation.
/// The result is: `[0, pubkey, created_at, kind, tags, content]`
pub fn serialize(&self) -> String {
serde_json::to_string(
&serde_json::json!([0, self.pubkey, self.created_at, self.kind, self.tags, self.content])
).expect("event serialization should never fail")
}
/// Compute the event ID by SHA-256 hashing the canonical serialization.
pub fn compute_id(&self) -> String {
let serialized = self.serialize();
let hash = Sha256::digest(serialized.as_bytes());
hex::encode(hash)
}
/// Check whether the event's `id` field matches the computed hash.
pub fn id_is_valid(&self) -> bool {
self.id == self.compute_id()
impl EventContent {
pub fn new(content: impl Into<String>, tags: Vec<Vec<String>>) -> Self {
EventContent { content: content.into(), tags }
}
}
```
## Verifying signatures
### `EventTemplate`
Nostr uses **Schnorr signatures** over the secp256k1 curve — the same curve used by Bitcoin.
The signature is computed over the event ID (which is already a hash), and can be verified
using the author's public key.
A template adds the numeric `kind` that decides how the content should be
interpreted. A kind-1 template is a short text note; a kind-6 template is a
repost; a kind-1984 template is a report. This chapter stays agnostic about
which kinds mean what — that's the next layer of the stack.
This means anyone can verify an event's authenticity without contacting the author or any
trusted authority. The math guarantees it.
```rust {file=coracle-lib/src/events.rs}
/// Event content plus its kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventTemplate {
pub content: String,
pub tags: Vec<Vec<String>>,
pub kind: u16,
}
```rust {file=coracle-lib/src/event.rs}
impl EventContent {
/// Tag the content with a kind, producing a publishable template.
pub fn kind(self, kind: u16) -> EventTemplate {
EventTemplate { content: self.content, tags: self.tags, kind }
}
}
```
### `StampedEvent`
Stamping adds the `created_at` timestamp. Callers usually pass
`SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()`, but the
library stays out of clock policy — the timestamp is whatever the caller says
it is.
```rust {file=coracle-lib/src/events.rs}
/// A template with a `created_at` timestamp attached.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StampedEvent {
pub content: String,
pub kind: u16,
pub tags: Vec<Vec<String>>,
pub created_at: u64,
}
impl EventTemplate {
pub fn stamp(self, created_at: u64) -> StampedEvent {
StampedEvent {
content: self.content,
kind: self.kind,
tags: self.tags,
created_at,
}
}
}
```
### `OwnedEvent`
Ownership means attaching an author. This is where the `PublicKey` from
chapter 2 shows up: the event is about to be something that some specific key
claims responsibility for.
```rust {file=coracle-lib/src/events.rs}
/// A stamped event with its author's public key attached.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedEvent {
pub content: String,
pub kind: u16,
pub tags: Vec<Vec<String>>,
pub created_at: u64,
pub pubkey: PublicKey,
}
impl StampedEvent {
pub fn own(self, pubkey: PublicKey) -> OwnedEvent {
OwnedEvent {
content: self.content,
kind: self.kind,
tags: self.tags,
created_at: self.created_at,
pubkey,
}
}
}
```
### `HashedEvent`
Hashing produces the event id — the 32-byte SHA-256 that NIP-01 defines as
the hash of a specific canonical serialization. The canonical form is a JSON
*array*, not an object:
```text
[0, pubkey, created_at, kind, tags, content]
```
The leading `0` is a version marker reserved by the protocol. The order is
fixed. Every nostr implementation on the network has to produce byte-
identical output from the same fields, or ids wouldn't match.
```rust {file=coracle-lib/src/events.rs}
fn canonical(
pubkey: &PublicKey,
created_at: u64,
kind: u16,
tags: &[Vec<String>],
content: &str,
) -> String {
serde_json::json!([
0,
pubkey.to_hex(),
created_at,
kind,
tags,
content,
])
.to_string()
}
/// An owned event with its computed id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashedEvent {
pub content: String,
pub kind: u16,
pub tags: Vec<Vec<String>>,
pub created_at: u64,
pub pubkey: PublicKey,
pub id: [u8; 32],
}
impl OwnedEvent {
pub fn hash(self) -> HashedEvent {
let id: [u8; 32] = Sha256::digest(
canonical(&self.pubkey, self.created_at, self.kind, &self.tags, &self.content)
.as_bytes(),
)
.into();
HashedEvent {
content: self.content,
kind: self.kind,
tags: self.tags,
created_at: self.created_at,
pubkey: self.pubkey,
id,
}
}
}
```
### `Event`
The final step attaches the signature and yields the type that goes on the
wire.
```rust {file=coracle-lib/src/events.rs}
/// A fully-formed nostr event: hashed and signed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
pub content: String,
pub kind: u16,
pub tags: Vec<Vec<String>>,
pub created_at: u64,
pub pubkey: PublicKey,
pub id: [u8; 32],
pub sig: [u8; 64],
}
impl HashedEvent {
pub fn sign(self, sig: [u8; 64]) -> Event {
Event {
content: self.content,
kind: self.kind,
tags: self.tags,
created_at: self.created_at,
pubkey: self.pubkey,
id: self.id,
sig,
}
}
}
```
The two-step split between `hash` and `sign` is deliberate. `hash` is pure
and knows nothing about keys; `sign` takes an already-computed signature as
input rather than doing the signing itself. That keeps the signing primitive
out of the event module, which is where we need it next.
## Signing a 32-byte message
We need exactly one new capability to drive the pipeline above end-to-end: a
way for a `SecretKey` to produce a Schnorr signature over 32 arbitrary bytes.
Not "sign an event" — just "sign these bytes." A future chapter will wrap
this in a `Signer` trait that knows about events; we want only one place in
the library where the cryptographic operation actually happens.
```rust {file=coracle-lib/src/keys.rs}
impl SecretKey {
/// Schnorr-sign a 32-byte message under this secret key. The digest
/// length is enforced at the type level; callers have to decide what
/// 32 bytes they're signing. Deterministic under BIP-340.
pub fn sign(&self, message: &[u8; 32]) -> [u8; 64] {
let keypair = secp256k1::Keypair::from_secret_key(SECP256K1, &self.0);
let msg = secp256k1::Message::from_digest(*message);
SECP256K1.sign_schnorr_no_aux_rand(&msg, &keypair).serialize()
}
}
```
With that in place, the full pipeline from "hello nostr" to a signed event is
one expression:
```rust
let hashed = EventContent::new("hello nostr", vec![])
.kind(1)
.stamp(1_700_000_000)
.own(secret.public_key())
.hash();
let event = hashed.clone().sign(secret.sign(&hashed.id));
```
The pipeline is illustrative, not tangled — the library does not ship an
`Event::sign_with` or `Signer` convenience yet. The later chapter on signers
wraps these inner two lines in a `Signer::sign_event` default so that remote
signers (NIP-07, NIP-46) plug into the same shape.
## Verification
Verification reverses the pipeline. Given an `Event`, recompute the canonical
form from its current fields, compare the resulting hash against the stored
`id`, and check the Schnorr signature.
```rust {file=coracle-lib/src/events.rs}
impl Event {
/// Verify the event's Schnorr signature against its public key and ID.
/// Returns `true` if the signature is valid and the ID matches the content.
pub fn verify(&self) -> bool {
if !self.id_is_valid() {
return false;
/// Recompute the event id and compare it to the stored `id`.
pub fn verify_id(&self) -> bool {
let expected: [u8; 32] = Sha256::digest(
canonical(&self.pubkey, self.created_at, self.kind, &self.tags, &self.content)
.as_bytes(),
)
.into();
expected == self.id
}
/// Verify both the id and the Schnorr signature.
pub fn verify(&self) -> Result<(), EventError> {
if !self.verify_id() {
return Err(EventError::InvalidId);
}
let msg = secp256k1::Message::from_digest(self.id);
let sig = secp256k1::schnorr::Signature::from_slice(&self.sig)
.map_err(|_| EventError::InvalidSignature)?;
let xonly = secp256k1::XOnlyPublicKey::from_slice(&self.pubkey.as_bytes())
.map_err(|_| EventError::InvalidSignature)?;
sig.verify(&msg, &xonly).map_err(|_| EventError::InvalidSignature)
}
}
```
The canonical-form helper is the single source of truth for "what bytes go
into the hash." Both `OwnedEvent::hash` and `Event::verify_id` call it, so
there is no way for the signing and verification paths to disagree about the
serialization.
## JSON
On the wire an event is the JSON object from the start of the chapter. The
binary fields — `id`, `pubkey`, and `sig` — are hex strings there, and
fixed-size byte arrays in memory. We hand-write `Serialize` and `Deserialize`
to bridge the two without pulling in `serde_with`.
```rust {file=coracle-lib/src/events.rs}
impl Serialize for Event {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut state = serializer.serialize_struct("Event", 7)?;
state.serialize_field("id", &hex::encode(self.id))?;
state.serialize_field("pubkey", &self.pubkey.to_hex())?;
state.serialize_field("created_at", &self.created_at)?;
state.serialize_field("kind", &self.kind)?;
state.serialize_field("tags", &self.tags)?;
state.serialize_field("content", &self.content)?;
state.serialize_field("sig", &hex::encode(self.sig))?;
state.end()
}
}
```
The deserializer walks the fields into `Option`s, validates hex shape at the
end, and silently ignores unknown keys — relays and clients sometimes attach
their own, and rejecting them would make this parser less permissive than
the network.
```rust {file=coracle-lib/src/events.rs}
impl<'de> Deserialize<'de> for Event {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_map(EventVisitor)
}
}
struct EventVisitor;
impl<'de> Visitor<'de> for EventVisitor {
type Value = Event;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a nostr event object")
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Event, M::Error> {
let mut id: Option<String> = None;
let mut pubkey: Option<String> = None;
let mut created_at: Option<u64> = None;
let mut kind: Option<u16> = None;
let mut tags: Option<Vec<Vec<String>>> = None;
let mut content: Option<String> = None;
let mut sig: Option<String> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"id" => id = Some(map.next_value()?),
"pubkey" => pubkey = Some(map.next_value()?),
"created_at" => created_at = Some(map.next_value()?),
"kind" => kind = Some(map.next_value()?),
"tags" => tags = Some(map.next_value()?),
"content" => content = Some(map.next_value()?),
"sig" => sig = Some(map.next_value()?),
_ => {
let _: serde::de::IgnoredAny = map.next_value()?;
}
}
}
let id_bytes = match hex::decode(&self.id) {
Ok(b) => b,
Err(_) => return false,
};
let sig_bytes = match hex::decode(&self.sig) {
Ok(b) => b,
Err(_) => return false,
};
let pubkey_bytes = match hex::decode(&self.pubkey) {
Ok(b) => b,
Err(_) => return false,
};
let id = decode_32(&id.ok_or_else(|| de::Error::missing_field("id"))?)
.map_err(|_| de::Error::custom("invalid hex in field `id`"))?;
let sig = decode_64(&sig.ok_or_else(|| de::Error::missing_field("sig"))?)
.map_err(|_| de::Error::custom("invalid hex in field `sig`"))?;
let pubkey = PublicKey::from_hex(
&pubkey.ok_or_else(|| de::Error::missing_field("pubkey"))?,
)
.map_err(|_| de::Error::custom("invalid hex in field `pubkey`"))?;
let msg = match secp256k1::Message::from_digest_slice(&id_bytes) {
Ok(m) => m,
Err(_) => return false,
};
let sig = match secp256k1::schnorr::Signature::from_slice(&sig_bytes) {
Ok(s) => s,
Err(_) => return false,
};
let pubkey = match secp256k1::XOnlyPublicKey::from_slice(&pubkey_bytes) {
Ok(k) => k,
Err(_) => return false,
};
sig.verify(&msg, &pubkey).is_ok()
Ok(Event {
id,
pubkey,
created_at: created_at.ok_or_else(|| de::Error::missing_field("created_at"))?,
kind: kind.ok_or_else(|| de::Error::missing_field("kind"))?,
tags: tags.ok_or_else(|| de::Error::missing_field("tags"))?,
content: content.ok_or_else(|| de::Error::missing_field("content"))?,
sig,
})
}
}
fn decode_32(s: &str) -> Result<[u8; 32], ()> {
let bytes = hex::decode(s).map_err(|_| ())?;
bytes.try_into().map_err(|_| ())
}
fn decode_64(s: &str) -> Result<[u8; 64], ()> {
let bytes = hex::decode(s).map_err(|_| ())?;
let arr: [u8; 64] = bytes.try_into().map_err(|_| ())?;
Ok(arr)
}
```
## Creating events
Deserialization does not verify the signature. Parsing is cheap; `verify()`
is not, and there are many places where you want to read an event off disk
without paying for a signature check.
To create an event, you provide the content fields and sign with a secret key. The process is:
## What's next
1. Fill in `pubkey`, `created_at`, `kind`, `tags`, and `content`
2. Compute the `id` from the canonical serialization
3. Sign the `id` with the secret key to produce `sig`
```rust {file=coracle-lib/src/event.rs}
impl Event {
/// Create a new signed event from the given fields and secret key.
///
/// The `pubkey`, `id`, and `sig` fields are computed automatically.
pub fn new(
kind: u16,
content: &str,
tags: Vec<Vec<String>>,
created_at: u64,
secret_key: &secp256k1::SecretKey,
) -> Self {
let secp = secp256k1::Secp256k1::new();
let keypair = secp256k1::Keypair::from_secret_key(&secp, secret_key);
let (pubkey, _parity) = keypair.x_only_public_key();
let mut event = Event {
id: String::new(),
pubkey: hex::encode(pubkey.serialize()),
created_at,
kind,
tags,
content: content.to_string(),
sig: String::new(),
};
event.id = event.compute_id();
let id_bytes = hex::decode(&event.id).expect("just computed, must be valid hex");
let msg = secp256k1::Message::from_digest_slice(&id_bytes)
.expect("SHA-256 output is always 32 bytes");
let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
event.sig = hex::encode(sig.serialize());
event
}
}
```
With this, you can create and verify events — the atomic unit of all nostr communication. In
the next chapter, we'll look at how keys work in more detail and introduce signing abstractions
that go beyond a raw secret key.
The next chapter gives `tags` a proper type. Tags are the structured half of
every event — references to other events, pubkeys, topics, relay hints — and
treating them as bare `Vec<Vec<String>>` gets tiresome fast.
+40 -41
View File
@@ -7,59 +7,58 @@
- [Keys](02-keys.md)
- [Encryption](03-encryption.md)
- [Events](04-events.md)
- [Signing](05-signing.md)
- [Tags](06-tags.md)
- [Kinds](07-kinds.md)
- [Kind Ranges](08-kind-ranges.md)
- [Addresses](09-addresses.md)
- [Proof of Work](10-proof-of-work.md)
- [Filters](11-filters.md)
- [Tags](05-tags.md)
- [Kinds](06-kinds.md)
- [Kind Ranges](07-kind-ranges.md)
- [Addresses](08-addresses.md)
- [Proof of Work](09-proof-of-work.md)
- [Filters](10-filters.md)
## Domain
- [Relay Selections](12-relay-selections.md)
- [Relay Metadata](13-relay-metadata.md)
- [Relay Membership](14-relay-membership.md)
- [Profiles](15-profiles.md)
- [Follows](16-follows.md)
- [Microblogging](17-microblogging.md)
- [Reactions](18-reactions.md)
- [Reports](19-reports.md)
- [Emojis](20-emojis.md)
- [Zaps](21-zaps.md)
- [Rooms](22-rooms.md)
- [Relay Selections](11-relay-selections.md)
- [Relay Metadata](12-relay-metadata.md)
- [Relay Membership](13-relay-membership.md)
- [Profiles](14-profiles.md)
- [Follows](15-follows.md)
- [Microblogging](16-microblogging.md)
- [Reactions](17-reactions.md)
- [Reports](18-reports.md)
- [Emojis](19-emojis.md)
- [Zaps](20-zaps.md)
- [Rooms](21-rooms.md)
## Networking
- [Relay Connections](23-relay-connections.md)
- [Relay Authentication](24-relay-authentication.md)
- [Relay Policies](25-relay-policies.md)
- [Server Authentication](26-server-authentication.md)
- [Relay Management API](27-relay-management-api.md)
- [Blossom Media Storage](28-blossom-media-storage.md)
- [Relay Connections](22-relay-connections.md)
- [Relay Authentication](23-relay-authentication.md)
- [Relay Policies](24-relay-policies.md)
- [Server Authentication](25-server-authentication.md)
- [Relay Management API](26-relay-management-api.md)
- [Blossom Media Storage](27-blossom-media-storage.md)
## Signers
- [Signer Interface](29-signer-interface.md)
- [Secret Signers](30-secret-signers.md)
- [Remote Signers](31-remote-signers.md)
- [Android Signers](32-android-signers.md)
- [Browser Signers](33-browser-signers.md)
- [Signer Interface](28-signer-interface.md)
- [Secret Signers](29-secret-signers.md)
- [Remote Signers](30-remote-signers.md)
- [Android Signers](31-android-signers.md)
- [Browser Signers](32-browser-signers.md)
## Content
- [Entities](34-entities.md)
- [Relays](35-relays.md)
- [Rooms](36-rooms.md)
- [Links](37-links.md)
- [Lightning](38-lightning.md)
- [Cashu](39-cashu.md)
- [Emojis](40-emojis.md)
- [Topics](41-topics.md)
- [Code](42-code.md)
- [Entities](33-entities.md)
- [Relays](34-relays.md)
- [Rooms](35-rooms.md)
- [Links](36-links.md)
- [Lightning](37-lightning.md)
- [Cashu](38-cashu.md)
- [Emojis](39-emojis.md)
- [Topics](40-topics.md)
- [Code](41-code.md)
## Storage
- [Event Repository](43-event-repository.md)
- [In Memory Backend](44-in-memory-backend.md)
- [Sqlite Backend](45-sqlite-backend.md)
- [Event Repository](42-event-repository.md)
- [In Memory Backend](43-in-memory-backend.md)
- [Sqlite Backend](44-sqlite-backend.md)
+234
View File
@@ -0,0 +1,234 @@
# Plan: Events
## Topic Summary
Introduce the core nostr `Event` type: the seven wire fields, JSON
(de)serialization, the NIP-01 canonical form used for ID computation, the
unsigned-vs-signed lifecycle distinction, and minimal structural validation. Add
a signing primitive on `SecretKey` that signs an arbitrary byte message (not an
event), and show — in prose, not in tangled code — how to combine `UnsignedEvent::id()`
and `SecretKey::sign()` to produce a complete signed `Event`. A richer
event-signing API and the `Signer` trait are deferred to chapter 05.
## Chapter Outline
1. **Opening framing.** Events as the entire nostr substrate: signed,
content-addressable, permissionless facts. Referential transparency. Why a
regular event's id is `sha256` of its canonical form. Connects back to
chapter 02 ("your public key is your name") and forward ("everything in this
book is built out of events").
2. **The module.** Register `events` in `lib.rs`. Imports.
3. **Errors.** `EventError` enum covering JSON failures, id mismatch, signature
failure, and structural problems.
4. **The wire structure.** Walk through the seven fields with a minimal JSON
example. Show the raw shape the network agrees on.
5. **The `Event` struct.** Define a plain struct with public fields, derive
`Clone`, `Debug`, `PartialEq`, `Eq`. Field types:
- `id: [u8; 32]`
- `pubkey: PublicKey` (from chapter 02)
- `created_at: u64`
- `kind: u16`
- `tags: Vec<Vec<String>>`
- `content: String`
- `sig: [u8; 64]`
Add `serde` impls (custom for hex-encoded binary fields, derived otherwise).
6. **Canonical form and the id.** Show `[0, pubkey, created_at, kind, tags, content]`.
Produce it with `serde_json::json!`. Hash with `sha2::Sha256`. Return 32 bytes.
Introduce `UnsignedEvent` struct (same fields minus `id` and `sig`) and put
`fn id(&self) -> [u8; 32]` on it.
7. **Signing primitive on `SecretKey`.** Add `SecretKey::sign(&self, msg: &[u8]) -> [u8; 64]`.
This belongs in `keys.rs` but is introduced *here* because this is where we
first need it. It wraps `secp256k1`'s Schnorr signing over a 32-byte digest,
using the shared `SECP256K1` context. Show a worked example — in prose, NOT
tangled — of `secret.sign(&unsigned.id())` producing a `[u8; 64]` that can be
combined with the `UnsignedEvent` fields to form an `Event`. This keeps
orchestration out of the core so chapter 05 can build a proper `Signer`
abstraction on top.
8. **Verification.** Two methods on `Event`:
- `fn verify_id(&self) -> bool` — recomputes the id from the other fields and
compares.
- `fn verify(&self) -> Result<(), EventError>` — verifies id and Schnorr
signature. Uses `secp256k1::Message::from_digest` + `verify_schnorr` with
the stored pubkey.
Note: no `PublicKey::verify` method yet — the verification goes directly
through `secp256k1` inside `Event::verify`. A future signer chapter can pull
that out.
9. **JSON round-tripping.** `Event` implements `Serialize`/`Deserialize`.
Because we store id/sig as bytes, we implement serde by hand via an
intermediate struct with hex-string fields. Highlight: incoming JSON is
validated structurally by serde but the id/sig are NOT cryptographically
validated on deserialization — that's what `verify()` is for.
10. **A worked example.** Build an unsigned text-note event, compute its id,
sign it, assemble the `Event`, serialize to JSON, parse it back, verify.
This block is illustrative — marked without a `{file=...}` annotation so
it is NOT tangled. It exists only in the narrative to show how the pieces
fit together.
11. **What's next.** Pointer forward to chapter 05 (Signing), which will wrap
`SecretKey::sign` in a proper `Signer` abstraction that knows how to sign
events directly.
## API Design
New in `coracle-lib/src/events.rs`:
```rust
pub enum EventError {
Json(String),
IdMismatch,
InvalidSignature,
InvalidHexField(&'static str),
}
pub struct Event {
pub id: [u8; 32],
pub pubkey: PublicKey,
pub created_at: u64,
pub kind: u16,
pub tags: Vec<Vec<String>>,
pub content: String,
pub sig: [u8; 64],
}
impl Event {
pub fn verify_id(&self) -> bool;
pub fn verify(&self) -> Result<(), EventError>;
}
impl Serialize for Event { ... }
impl<'de> Deserialize<'de> for Event { ... }
pub struct UnsignedEvent {
pub pubkey: PublicKey,
pub created_at: u64,
pub kind: u16,
pub tags: Vec<Vec<String>>,
pub content: String,
}
impl UnsignedEvent {
pub fn id(&self) -> [u8; 32];
}
```
New in `coracle-lib/src/keys.rs` (addition):
```rust
impl SecretKey {
pub fn sign(&self, message: &[u8; 32]) -> [u8; 64];
}
```
Taking `&[u8; 32]` rather than `&[u8]` encodes Schnorr-over-a-digest at the
type level and matches `secp256k1::Message::from_digest`. The chapter notes that
this is "sign an arbitrary 32-byte message," not "sign an event."
## Code Organization
- `coracle-lib/src/events.rs` — new file, all event-related types.
- `coracle-lib/src/lib.rs` — register `pub mod events;`.
- `coracle-lib/src/keys.rs` — append a small block adding `SecretKey::sign`.
- `coracle-lib/tests/events.rs` — hand-written integration tests (not tangled):
round-trip a known event, verify a pre-signed fixture, reject tampered id/sig.
## Dependencies
All already in `coracle-lib/Cargo.toml`:
- `serde`, `serde_json` — JSON and canonical form
- `sha2` — event id hash
- `secp256k1` — Schnorr signing and verification
- `hex` — id/sig hex encoding in serde impls
No new dependencies.
## Narrative Notes
- **Open with referential transparency.** The single most important concept in
this chapter is that the event id is a function of the content. Lead with
that, not with field enumeration.
- **Canonical form deserves its own subsection.** Readers coming from
conventional APIs will expect "just JSON"; it's worth stopping to explain why
nostr uses a positional array, not an object, to compute the hash.
- **Two separate structs, not `Option<id>`.** Explain the choice: making the
lifecycle visible in types is worth the slightly larger API surface in a
teaching library. Reference rust-nostr's similar approach.
- **Why `sign` is on `SecretKey`, not `Event`.** Keep signing as a low-level
primitive over bytes. Chapter 05 will introduce a `Signer` trait that
abstracts over local/remote/browser signers; only that trait will have an
`sign_event` method. Keeping `Event` signer-agnostic now avoids having to
retrofit it later.
- **Show the worked example early enough that readers can see the pieces click.**
Put it right after verification so they see create → hash → sign → verify in
one flow.
- **Structural validation is shallow.** Point out explicitly that `verify()`
does the real work and structural validation is about rejecting garbage, not
about enforcing semantics. Tie back to the "validation philosophy" from
chapter 01's framing and from the building-nostr philosophy.
- **Custom serde for binary fields.** Walk through why we hand-write
`Serialize`/`Deserialize`: the wire format expects id/sig as lowercase hex
strings, but we want to hold them as `[u8; 32]`/`[u8; 64]` internally. An
intermediate struct with hex `String` fields + `From` conversions is the
cleanest approach.
## Design Decisions
1. **Plain struct with public fields.** Matches rust-nostr. No getters, no
setters, no builder — the teaching goal is clarity about what an event *is*.
A builder can come later if it earns its place.
2. **Separate `UnsignedEvent`.** Option 1 from the research. Clearer lifecycle
in types; avoids `Option` juggling.
3. **`[u8; 32]` / `[u8; 64]` for id/sig, not newtypes.** Keep the type surface
small for this chapter. A later chapter can promote them to `EventId` etc. if
it's worth it. The serde impl hides the raw array at the JSON boundary.
4. **Custom serde impls via an intermediate hex-string struct.** Mirrors
rust-nostr's `EventIntermediate` approach but simpler: one owned struct for
deserialization, hand-written `Serialize` for output. Avoids pulling in
`serde_with` or writing `serialize_with` attributes.
5. **`SecretKey::sign` takes `&[u8; 32]`, not `&[u8]`.** Schnorr signs a 32-byte
digest; the fixed-size array makes that contract visible and avoids runtime
errors. Matches `secp256k1::Message::from_digest`.
6. **No `PublicKey::verify` yet.** One primitive at a time. Verification lives
inside `Event::verify` for now, which hides the secp256k1 details.
Chapter 05 can pull it out when the `Signer` trait emerges.
7. **`u64` for `created_at`.** Unix seconds fit comfortably. A signed int would
suggest pre-epoch events are meaningful; they aren't in nostr.
8. **`u16` for `kind`.** nostr kinds fit in 16 bits and the canonical form
serializes them as bare integers. No enum yet — that's chapter 07.
9. **Tags as `Vec<Vec<String>>`.** Matches every reference. Richer tag types
wait for chapter 06.
10. **Worked example is NOT tangled.** Per user's explicit instruction: show
event signing as a prose example using the `sign` primitive, but do not
generate a `sign_event` or similar helper in the library.
## Open Questions
- **Should `Event::verify` validate structural invariants (pubkey length, hex
shape of tags, etc.) in addition to id and signature?** Leaning no — those are
caught at deserialization by serde. `verify()` should be *cryptographic*
verification only. Will decide in writing.
- **Error granularity for JSON failures.** Keep a single `Json(String)` variant,
or split into `InvalidJson` / `MissingField` / `WrongType`? Leaning on a
single variant to match the style of the keys chapter's `KeyError`.
- **Should `UnsignedEvent` have a convenience constructor for kind-1 text notes
with `created_at = now()`?** Leaning no — this chapter should show the raw
fields; builders and helpers can come later without breaking anything.
+302
View File
@@ -0,0 +1,302 @@
# 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>>`.