9.8 KiB
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
-
Opening framing. Events as the entire nostr substrate: signed, content-addressable, permissionless facts. Referential transparency. Why a regular event's id is
sha256of 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"). -
The module. Register
eventsinlib.rs. Imports. -
Errors.
EventErrorenum covering JSON failures, id mismatch, signature failure, and structural problems. -
The wire structure. Walk through the seven fields with a minimal JSON example. Show the raw shape the network agrees on.
-
The
Eventstruct. Define a plain struct with public fields, deriveClone,Debug,PartialEq,Eq. Field types:id: [u8; 32]pubkey: PublicKey(from chapter 02)created_at: u64kind: u16tags: Vec<Vec<String>>content: Stringsig: [u8; 64]Addserdeimpls (custom for hex-encoded binary fields, derived otherwise).
-
Canonical form and the id. Show
[0, pubkey, created_at, kind, tags, content]. Produce it withserde_json::json!. Hash withsha2::Sha256. Return 32 bytes. IntroduceUnsignedEventstruct (same fields minusidandsig) and putfn id(&self) -> [u8; 32]on it. -
Signing primitive on
SecretKey. AddSecretKey::sign(&self, msg: &[u8]) -> [u8; 64]. This belongs inkeys.rsbut is introduced here because this is where we first need it. It wrapssecp256k1's Schnorr signing over a 32-byte digest, using the sharedSECP256K1context. Show a worked example — in prose, NOT tangled — ofsecret.sign(&unsigned.id())producing a[u8; 64]that can be combined with theUnsignedEventfields to form anEvent. This keeps orchestration out of the core so chapter 05 can build a properSignerabstraction on top. -
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. Usessecp256k1::Message::from_digest+verify_schnorrwith the stored pubkey. Note: noPublicKey::verifymethod yet — the verification goes directly throughsecp256k1insideEvent::verify. A future signer chapter can pull that out.
-
JSON round-tripping.
EventimplementsSerialize/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 whatverify()is for. -
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. -
What's next. Pointer forward to chapter 05 (Signing), which will wrap
SecretKey::signin a properSignerabstraction that knows how to sign events directly.
API Design
New in coracle-lib/src/events.rs:
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):
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— registerpub mod events;.coracle-lib/src/keys.rs— append a small block addingSecretKey::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 formsha2— event id hashsecp256k1— Schnorr signing and verificationhex— 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
signis onSecretKey, notEvent. Keep signing as a low-level primitive over bytes. Chapter 05 will introduce aSignertrait that abstracts over local/remote/browser signers; only that trait will have ansign_eventmethod. KeepingEventsigner-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 hexStringfields +Fromconversions is the cleanest approach.
Design Decisions
-
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.
-
Separate
UnsignedEvent. Option 1 from the research. Clearer lifecycle in types; avoidsOptionjuggling. -
[u8; 32]/[u8; 64]for id/sig, not newtypes. Keep the type surface small for this chapter. A later chapter can promote them toEventIdetc. if it's worth it. The serde impl hides the raw array at the JSON boundary. -
Custom serde impls via an intermediate hex-string struct. Mirrors rust-nostr's
EventIntermediateapproach but simpler: one owned struct for deserialization, hand-writtenSerializefor output. Avoids pulling inserde_withor writingserialize_withattributes. -
SecretKey::signtakes&[u8; 32], not&[u8]. Schnorr signs a 32-byte digest; the fixed-size array makes that contract visible and avoids runtime errors. Matchessecp256k1::Message::from_digest. -
No
PublicKey::verifyyet. One primitive at a time. Verification lives insideEvent::verifyfor now, which hides the secp256k1 details. Chapter 05 can pull it out when theSignertrait emerges. -
u64forcreated_at. Unix seconds fit comfortably. A signed int would suggest pre-epoch events are meaningful; they aren't in nostr. -
u16forkind. nostr kinds fit in 16 bits and the canonical form serializes them as bare integers. No enum yet — that's chapter 07. -
Tags as
Vec<Vec<String>>. Matches every reference. Richer tag types wait for chapter 06. -
Worked example is NOT tangled. Per user's explicit instruction: show event signing as a prose example using the
signprimitive, but do not generate asign_eventor similar helper in the library.
Open Questions
-
Should
Event::verifyvalidate 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 intoInvalidJson/MissingField/WrongType? Leaning on a single variant to match the style of the keys chapter'sKeyError. -
Should
UnsignedEventhave a convenience constructor for kind-1 text notes withcreated_at = now()? Leaning no — this chapter should show the raw fields; builders and helpers can come later without breaking anything.