409 lines
14 KiB
Markdown
409 lines
14 KiB
Markdown
# 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 10000–19999), the identifier is empty — the relay keeps one event
|
||
per author and kind. For an addressable event (kind 30000–39999), 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.
|