235 lines
9.8 KiB
Markdown
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.
|