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

14 KiB
Raw Blame History

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

pub mod addresses;
//! 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.

/// 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.

/// 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.

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.

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.

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

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.