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