Add events chapter
This commit is contained in:
+449
-165
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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>>`.
|
||||
Reference in New Issue
Block a user