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

235 lines
9.8 KiB
Markdown

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