Files
coracle-rust/book/07-addresses.md
T
2026-04-17 16:43:56 -07:00

409 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Addresses
An event's id is a hash of its contents. Change the content, sign again, and
you get a different id. That's fine for regular events — they're immutable,
so the id is a permanent name. But replaceable and addressable events are
designed to be overwritten. A user's profile (kind 0) updates every time they
change their display name. An article (kind 30023) updates every time the
author edits a paragraph. The old id is gone; a new one takes its place.
You need a way to point at the *slot* rather than the *version*. That's what
an address is: the triple `kind:pubkey:identifier` that stays the same no
matter how many times the event is replaced. For a replaceable event (kind
0, 3, or 1000019999), the identifier is empty — the relay keeps one event
per author and kind. For an addressable event (kind 3000039999), the
identifier is the `d` tag's value, which partitions the kind further so
that a single author can maintain many independent slots under the same kind.
The `a` tag carries this triple as its value, and the `naddr` bech32 encoding
wraps it for sharing. This chapter builds the `Address` type that handles
both.
## The module
```rust {file=coracle-lib/src/lib.rs}
pub mod addresses;
```
```rust {file=coracle-lib/src/addresses.rs}
//! Event addresses: stable references to replaceable and addressable events.
//!
//! An [`Address`] is the triple `(kind, pubkey, identifier)` that names the
//! slot a mutable event occupies, independent of any particular version.
use std::fmt;
use std::str::FromStr;
use bech32::{Bech32, Hrp};
use crate::events::Event;
use crate::keys::{KeyError, PublicKey};
use crate::kinds;
```
## Errors
Address operations can fail when parsing a string or decoding bech32. A
single error type covers all the ways.
```rust {file=coracle-lib/src/addresses.rs}
/// Errors that can occur when parsing or encoding an address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AddressError {
/// The string could not be parsed as `kind:pubkey:identifier`.
InvalidFormat,
/// The bech32 string was malformed.
InvalidBech32,
/// The bech32 prefix was not `naddr`.
WrongPrefix { found: String },
/// The public key bytes were not a valid secp256k1 point.
InvalidKey,
/// A required TLV field was missing.
FieldMissing(&'static str),
/// The TLV data was malformed.
TlvError,
}
impl fmt::Display for AddressError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AddressError::InvalidFormat => write!(f, "invalid address format"),
AddressError::InvalidBech32 => write!(f, "invalid bech32 encoding"),
AddressError::WrongPrefix { found } => {
write!(f, "wrong bech32 prefix: expected naddr, found {found}")
}
AddressError::InvalidKey => write!(f, "invalid public key"),
AddressError::FieldMissing(name) => {
write!(f, "missing required field: {name}")
}
AddressError::TlvError => write!(f, "malformed TLV data"),
}
}
}
impl std::error::Error for AddressError {}
impl From<KeyError> for AddressError {
fn from(_: KeyError) -> Self {
AddressError::InvalidKey
}
}
```
## The `Address` struct
An address has three fields that identify the slot — `kind`, `pubkey`, and
`identifier` — plus a `hints` field that carries relay URLs where the event
might be found. The hints are transport metadata: they travel inside `naddr`
bech32 strings and `a` tags but are not part of the logical identity. Two
addresses that differ only in their hints point at the same event.
```rust {file=coracle-lib/src/addresses.rs}
/// A stable reference to a replaceable or addressable event.
///
/// The triple `(kind, pubkey, identifier)` names the slot a mutable event
/// occupies. Two events with the same address are versions of the same
/// logical object — the relay keeps whichever has the later `created_at`.
///
/// `hints` carries relay URLs where the event might be found. These are
/// transport metadata used when encoding to `naddr` or `a` tags, but are
/// not part of the address identity — `PartialEq` and `Hash` ignore them.
#[derive(Debug, Clone)]
pub struct Address {
/// The event kind.
pub kind: u16,
/// The author's public key.
pub pubkey: PublicKey,
/// The `d` tag value. Empty for plain replaceable events.
pub identifier: String,
/// Relay URLs where this event might be found.
pub hints: Vec<String>,
}
impl PartialEq for Address {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.pubkey == other.pubkey
&& self.identifier == other.identifier
}
}
impl Eq for Address {}
impl std::hash::Hash for Address {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.kind.hash(state);
self.pubkey.hash(state);
self.identifier.hash(state);
}
}
impl Address {
/// Create a new address from its components.
pub fn new(kind: u16, pubkey: PublicKey, identifier: impl Into<String>) -> Self {
Address {
kind,
pubkey,
identifier: identifier.into(),
hints: Vec::new(),
}
}
/// Attach relay hints to this address.
pub fn hints<I, S>(mut self, relays: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.hints = relays.into_iter().map(Into::into).collect();
self
}
}
```
`PartialEq` and `Hash` are hand-implemented to exclude `hints`. Two
addresses that name the same slot are equal regardless of which relays
they suggest — the hints are advice, not identity.
### Building from an event
The most common way to get an address is from an event you already have.
For addressable events, the identifier comes from the `d` tag (defaulting
to the empty string if absent). For plain replaceable events, the
identifier is always empty — even if a `d` tag happens to be present, it
has no meaning for replacement semantics, so we ignore it. If the kind is
neither replaceable nor addressable, there is no address — the event is
immutable and identified by its id alone.
```rust {file=coracle-lib/src/addresses.rs}
impl Address {
/// Extract the address from an event, if the event's kind is
/// replaceable or addressable.
///
/// Returns `None` for regular and ephemeral events. The returned
/// address has no relay hints — call `.hints(...)` to attach them.
pub fn from_event(event: &Event) -> Option<Self> {
let identifier = if kinds::is_addressable(event.kind) {
event.tags.value("d").unwrap_or("").to_string()
} else if kinds::is_replaceable(event.kind) {
String::new()
} else {
return None;
};
Some(Address {
kind: event.kind,
pubkey: event.pubkey,
identifier,
hints: Vec::new(),
})
}
}
```
## String format
The canonical string form of an address is `kind:pubkey:identifier` — the
same value that goes inside an `a` tag. `Display` writes it, `FromStr`
parses it.
Parsing splits on `:` and takes the first segment as the kind, the second
as the pubkey hex, and everything after the second colon as the identifier.
Joining the remainder handles identifiers that themselves contain colons,
which is unusual but legal.
```rust {file=coracle-lib/src/addresses.rs}
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}:{}", self.kind, self.pubkey, self.identifier)
}
}
impl FromStr for Address {
type Err = AddressError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Try naddr bech32 first.
if s.starts_with("naddr1") {
return Self::from_naddr(s);
}
let mut parts = s.splitn(3, ':');
let kind: u16 = parts
.next()
.and_then(|s| s.parse().ok())
.ok_or(AddressError::InvalidFormat)?;
let pubkey = parts
.next()
.ok_or(AddressError::InvalidFormat)
.and_then(|s| PublicKey::from_hex(s).map_err(|_| AddressError::InvalidFormat))?;
let identifier = parts.next().unwrap_or("").to_string();
Ok(Address {
kind,
pubkey,
identifier,
hints: Vec::new(),
})
}
}
```
`FromStr` also accepts `naddr1…` strings, so callers can parse either
format with a single `.parse()` call — the same convenience we used for
public keys accepting both hex and `npub`.
## NIP-19 `naddr` encoding
The simple bech32 encodings we built in the keys chapter — `npub` and `nsec`
— wrap a fixed 32-byte payload. An address carries variable-length data: the
identifier can be any length, and there may be zero or more relay hints. NIP-19
handles this with a **TLV** (type-length-value) scheme inside the bech32
payload.
Each TLV entry is:
| Byte | Meaning |
|------|---------|
| 0 | Type tag: what this entry represents |
| 1 | Length: how many bytes follow |
| 2..2+len | Value |
Four type tags are defined:
| Type | Name | Value |
|------|------|-------|
| 0 | special | The identifier (`d` tag), UTF-8 bytes |
| 1 | relay | A relay URL, UTF-8 bytes |
| 2 | author | The pubkey, 32 raw bytes |
| 3 | kind | The kind number, 4-byte big-endian u32 |
An `naddr` payload concatenates one entry for the identifier, one for
the author, one for the kind, and zero or more for relay hints. The
order of these entries is identifier first by convention, but decoders
should not rely on ordering.
```rust {file=coracle-lib/src/addresses.rs}
const NADDR_HRP: Hrp = Hrp::parse_unchecked("naddr");
const TLV_SPECIAL: u8 = 0;
const TLV_RELAY: u8 = 1;
const TLV_AUTHOR: u8 = 2;
const TLV_KIND: u8 = 3;
```
### Encoding
```rust {file=coracle-lib/src/addresses.rs}
impl Address {
/// Encode as an `naddr1…` bech32 string per NIP-19.
///
/// Any relay hints on this address are embedded in the TLV payload.
pub fn to_naddr(&self) -> String {
let id_bytes = self.identifier.as_bytes();
let relay_len: usize = self.hints.iter().map(|r| 2 + r.len()).sum();
let capacity = (2 + id_bytes.len()) + (2 + 32) + (2 + 4) + relay_len;
let mut buf = Vec::with_capacity(capacity);
// Identifier (TLV type 0)
buf.push(TLV_SPECIAL);
buf.push(id_bytes.len() as u8);
buf.extend_from_slice(id_bytes);
// Relays (TLV type 1, one entry per relay)
for relay in &self.hints {
buf.push(TLV_RELAY);
buf.push(relay.len() as u8);
buf.extend_from_slice(relay.as_bytes());
}
// Author (TLV type 2)
buf.push(TLV_AUTHOR);
buf.push(32);
buf.extend_from_slice(&self.pubkey.as_bytes());
// Kind (TLV type 3, 4-byte big-endian u32)
buf.push(TLV_KIND);
buf.push(4);
buf.extend_from_slice(&(self.kind as u32).to_be_bytes());
bech32::encode::<Bech32>(NADDR_HRP, &buf)
.expect("naddr encoding cannot fail for well-formed TLV")
}
/// Decode an `naddr1…` bech32 string into an address.
///
/// Any relay hints embedded in the payload are stored in the
/// returned address's `hints` field.
pub fn from_naddr(s: &str) -> Result<Self, AddressError> {
let (hrp, data) = bech32::decode(s).map_err(|_| AddressError::InvalidBech32)?;
if hrp != NADDR_HRP {
return Err(AddressError::WrongPrefix {
found: hrp.to_string(),
});
}
let mut identifier: Option<String> = None;
let mut pubkey: Option<PublicKey> = None;
let mut kind: Option<u16> = None;
let mut hints: Vec<String> = Vec::new();
let mut pos = 0;
while pos < data.len() {
let t = *data.get(pos).ok_or(AddressError::TlvError)?;
let l = *data.get(pos + 1).ok_or(AddressError::TlvError)? as usize;
let value = data
.get(pos + 2..pos + 2 + l)
.ok_or(AddressError::TlvError)?;
pos += 2 + l;
match t {
TLV_SPECIAL => {
if identifier.is_none() {
identifier = Some(
String::from_utf8(value.to_vec())
.map_err(|_| AddressError::TlvError)?,
);
}
}
TLV_RELAY => {
if let Ok(url) = String::from_utf8(value.to_vec()) {
hints.push(url);
}
}
TLV_AUTHOR => {
if pubkey.is_none() {
pubkey = Some(PublicKey::from_bytes(value)?);
}
}
TLV_KIND => {
if kind.is_none() {
let bytes: [u8; 4] =
value.try_into().map_err(|_| AddressError::TlvError)?;
kind = Some(u32::from_be_bytes(bytes) as u16);
}
}
_ => {} // Unknown TLV types are ignored for forward compatibility.
}
}
Ok(Address {
kind: kind.ok_or(AddressError::FieldMissing("kind"))?,
pubkey: pubkey.ok_or(AddressError::FieldMissing("pubkey"))?,
identifier: identifier.ok_or(AddressError::FieldMissing("identifier"))?,
hints,
})
}
}
```
The decoder takes first-occurrence-wins for each field type, which matches
what every other implementation does. Unknown TLV types are silently
skipped — future NIPs may add new ones, and rejecting them would break
forward compatibility.
## What's next
We now have a way to name mutable events that survives content changes.
The next chapter introduces proof of work — the optional CPU cost a client
can burn into an event id to signal effort and reduce spam.