Files
2026-05-20 14:27:26 -07:00

613 lines
19 KiB
Markdown

# Events
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.
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.
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;
use crate::tags::Tags;
```
## 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": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65",
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"created_at": 1673347337,
"kind": 1,
"tags": [["t", "nostr"]],
"content": "hello nostr",
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd0092619a2c8c1221e581946e0191f2af505dfdf8657a414dbca329186f009262"
}
```
`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.
## Building up an event
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.
### `EventContent`
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.
`EventContent` is a builder. `new` hands back an empty payload and
chainable `content` and `tags` setters fill it in. Neither field is
required at construction time — a zero-length note with no tags is a
perfectly legal kind-1 event — and the builder shape means callers
don't have to pass `""` or `vec![]` placeholders when they only care
about one of the two.
```rust {file=coracle-lib/src/events.rs}
/// The content of an event: the human-readable body plus its tags.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EventContent {
pub content: String,
pub tags: Tags,
}
impl EventContent {
/// An empty payload: no content, no tags.
pub fn new() -> Self {
Self::default()
}
/// Set the human-readable content. Returns `self` so the call can
/// chain.
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
/// Set the tags. Returns `self` so the call can chain.
pub fn tags(mut self, tags: impl Into<Tags>) -> Self {
self.tags = tags.into();
self
}
}
```
### `EventTemplate`
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.
```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: Tags,
pub kind: u16,
}
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. The library stays out of clock
policy — the timestamp is whatever the caller says it is — but in practice
"whatever the caller says" is almost always "right now," so we give that
common case a name. The `now` helper lives in a `util` module, the home for
the small, stateless helpers that don't belong to any one type.
```rust {file=coracle-lib/src/lib.rs}
pub mod util;
```
```rust {file=coracle-lib/src/util.rs}
//! Small stateless helpers shared across the library.
use std::time::{SystemTime, UNIX_EPOCH};
/// The current time as a Unix timestamp in seconds.
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
```
Stamping a template with the current time then reads as
`template.stamp(now())`. `stamp` itself takes a plain `u64`, leaving the
choice of clock to the caller.
```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: Tags,
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: Tags,
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: &Tags,
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: Tags,
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: Tags,
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()
.content("hello nostr")
.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 {
/// 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<Tags> = 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 = 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`"))?;
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)
}
```
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.
## A shared surface across event stages
There is a lot of overlap between various event structs. A few minimal accessor
traits capture read access to various fields so that we can define extensions
to events without duplication.
```rust {file=coracle-lib/src/events.rs}
pub trait HasTags {
fn tags(&self) -> &Tags;
}
pub trait HasId {
fn id(&self) -> &[u8; 32];
}
impl HasTags for HashedEvent {
fn tags(&self) -> &Tags {
&self.tags
}
}
impl HasTags for Event {
fn tags(&self) -> &Tags {
&self.tags
}
}
impl HasId for HashedEvent {
fn id(&self) -> &[u8; 32] {
&self.id
}
}
impl HasId for Event {
fn id(&self) -> &[u8; 32] {
&self.id
}
}
```
Subsequent chapters will add *extension traits* bounded on these accessors,
supply the method bodies as defaults, and close with a blanket impl over every
type that satisfies the bound. The logic is written once and reaches both event
types — and any event type added later — without another line per struct.
The expiration chapter's version reads like this:
```rust
pub trait EventExtensionExpiration: HasTags {
fn is_expired(&self) -> bool {
is_expired(self.tags())
}
// ...the rest of the expiration queries
}
impl<T: HasTags> EventExtensionExpiration for T {}
```
A trait's methods are only callable where the trait is in scope, so the
library gathers these extension traits in a `prelude`. One glob import brings
the whole behavior-query surface along:
```rust
use coracle_lib::prelude::*;
```
```rust {file=coracle-lib/src/lib.rs}
pub mod prelude;
```
```rust {file=coracle-lib/src/prelude.rs}
//! One-stop import for the extension traits that add behavior-tag queries to
//! events. With `coracle_lib::prelude::*` in scope, methods like
//! `is_expired`, `is_protected`, and `get_pow` become callable on
//! `HashedEvent` and `Event`.
pub use crate::events::{HasId, HasTags};
```
Each later chapter that defines an extension trait adds it to this prelude.
## What's next
The next chapter introduces kinds — the integer that decides how content
and tags on a given event should be read — and with it the beginning of
a taxonomy we have so far resisted building.