613 lines
19 KiB
Markdown
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.
|