Files
coracle-rust/book/plan/events.md
T
2026-04-14 14:25:54 -07:00

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

  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:

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 — 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.