7.0 KiB
Events
Everything in nostr is an event. A short note, a profile update, a reaction, a relay list — they're all events. An event is a JSON object signed by its author's cryptographic key. Once signed, it cannot be altered without invalidating the signature, which means any relay or client can verify that an event is authentic without trusting anyone.
The event structure
Here's what a nostr event looks like as JSON:
{
"id": "4376c65d2f232afbe9b882a35baa4f6fe8bce184396e157b1a35b3f01b3e285c",
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"created_at": 1673347337,
"kind": 1,
"tags": [
["e", "3da979448d9ba263864c4d6f14984c423a3838364ec255f03c7904b1ae77f206"],
["p", "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce"]
],
"content": "Hello, nostr!",
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd..."
}
Let's define this in Rust. We'll start with the module declaration:
pub mod event;
And now the Event struct itself. Each field maps directly to the JSON above:
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
/// A nostr event — the fundamental data type of the protocol.
///
/// Every interaction on nostr is represented as a signed event. The `id` is a
/// SHA-256 hash of the canonical serialization, and the `sig` is a Schnorr
/// signature over that hash using the author's private key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// The event ID: a 32-byte hex-encoded SHA-256 hash of the canonical serialization.
pub id: String,
/// The author's public key: a 32-byte hex-encoded secp256k1 public key (x-only).
pub pubkey: String,
/// Unix timestamp in seconds when the event was created.
pub created_at: u64,
/// The event kind determines how the event should be interpreted.
/// Kind 0 = metadata, kind 1 = short text note, kind 3 = contacts, etc.
pub kind: u16,
/// A list of tags. Each tag is an array of strings where the first element
/// identifies the tag type. Common tags: ["e", <event-id>], ["p", <pubkey>].
pub tags: Vec<Vec<String>>,
/// The event content. Interpretation depends on the kind.
pub content: String,
/// A 64-byte hex-encoded Schnorr signature of the event ID.
pub sig: String,
}
Computing the event ID
The event ID is not chosen by the author — it's derived from the event's content via a deterministic process. This is what makes events tamper-proof: change any field and the ID changes, which invalidates the signature.
The ID is the SHA-256 hash of a specific JSON serialization called the canonical form:
[0, <pubkey>, <created_at>, <kind>, <tags>, <content>]
That leading 0 is a version number reserved for future use. The array is serialized as
compact JSON (no whitespace) and UTF-8 encoded before hashing.
impl Event {
/// Serialize this event into the canonical form used for ID computation.
/// The result is: `[0, pubkey, created_at, kind, tags, content]`
pub fn serialize(&self) -> String {
serde_json::to_string(
&serde_json::json!([0, self.pubkey, self.created_at, self.kind, self.tags, self.content])
).expect("event serialization should never fail")
}
/// Compute the event ID by SHA-256 hashing the canonical serialization.
pub fn compute_id(&self) -> String {
let serialized = self.serialize();
let hash = Sha256::digest(serialized.as_bytes());
hex::encode(hash)
}
/// Check whether the event's `id` field matches the computed hash.
pub fn id_is_valid(&self) -> bool {
self.id == self.compute_id()
}
}
Verifying signatures
Nostr uses Schnorr signatures over the secp256k1 curve — the same curve used by Bitcoin. The signature is computed over the event ID (which is already a hash), and can be verified using the author's public key.
This means anyone can verify an event's authenticity without contacting the author or any trusted authority. The math guarantees it.
impl Event {
/// Verify the event's Schnorr signature against its public key and ID.
/// Returns `true` if the signature is valid and the ID matches the content.
pub fn verify(&self) -> bool {
if !self.id_is_valid() {
return false;
}
let id_bytes = match hex::decode(&self.id) {
Ok(b) => b,
Err(_) => return false,
};
let sig_bytes = match hex::decode(&self.sig) {
Ok(b) => b,
Err(_) => return false,
};
let pubkey_bytes = match hex::decode(&self.pubkey) {
Ok(b) => b,
Err(_) => return false,
};
let msg = match secp256k1::Message::from_digest_slice(&id_bytes) {
Ok(m) => m,
Err(_) => return false,
};
let sig = match secp256k1::schnorr::Signature::from_slice(&sig_bytes) {
Ok(s) => s,
Err(_) => return false,
};
let pubkey = match secp256k1::XOnlyPublicKey::from_slice(&pubkey_bytes) {
Ok(k) => k,
Err(_) => return false,
};
sig.verify(&msg, &pubkey).is_ok()
}
}
Creating events
To create an event, you provide the content fields and sign with a secret key. The process is:
- Fill in
pubkey,created_at,kind,tags, andcontent - Compute the
idfrom the canonical serialization - Sign the
idwith the secret key to producesig
impl Event {
/// Create a new signed event from the given fields and secret key.
///
/// The `pubkey`, `id`, and `sig` fields are computed automatically.
pub fn new(
kind: u16,
content: &str,
tags: Vec<Vec<String>>,
created_at: u64,
secret_key: &secp256k1::SecretKey,
) -> Self {
let secp = secp256k1::Secp256k1::new();
let keypair = secp256k1::Keypair::from_secret_key(&secp, secret_key);
let (pubkey, _parity) = keypair.x_only_public_key();
let mut event = Event {
id: String::new(),
pubkey: hex::encode(pubkey.serialize()),
created_at,
kind,
tags,
content: content.to_string(),
sig: String::new(),
};
event.id = event.compute_id();
let id_bytes = hex::decode(&event.id).expect("just computed, must be valid hex");
let msg = secp256k1::Message::from_digest_slice(&id_bytes)
.expect("SHA-256 output is always 32 bytes");
let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
event.sig = hex::encode(sig.serialize());
event
}
}
With this, you can create and verify events — the atomic unit of all nostr communication. In the next chapter, we'll look at how keys work in more detail and introduce signing abstractions that go beyond a raw secret key.