Add addresses chapter

This commit is contained in:
Jon Staab
2026-04-17 16:43:56 -07:00
parent c7ec9f18fa
commit b96ad44d3a
7 changed files with 826 additions and 41 deletions
+7
View File
@@ -133,6 +133,13 @@ impl PublicKey {
.map_err(|_| KeyError::InvalidKey)?; .map_err(|_| KeyError::InvalidKey)?;
Ok(PublicKey(inner)) Ok(PublicKey(inner))
} }
/// Parse from raw bytes (exactly 32).
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KeyError> {
let inner = secp256k1::XOnlyPublicKey::from_slice(bytes)
.map_err(|_| KeyError::InvalidKey)?;
Ok(PublicKey(inner))
}
} }
``` ```
+3 -3
View File
@@ -267,6 +267,6 @@ define a struct, implement `Kind`, and the trait provides the rest.
## What's next ## What's next
The next chapter looks more closely at kind ranges — the system that The next chapter introduces addresses — the stable `kind:pubkey:identifier`
ties storage behavior to kind numbers — and what that means for relay references that let you point at a replaceable or addressable event without
queries and event addressing. tying yourself to a particular version's id.
+408
View File
@@ -0,0 +1,408 @@
# 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.
+37 -38
View File
@@ -9,56 +9,55 @@
- [Tags](04-tags.md) - [Tags](04-tags.md)
- [Events](05-events.md) - [Events](05-events.md)
- [Kinds](06-kinds.md) - [Kinds](06-kinds.md)
- [Kind Ranges](07-kind-ranges.md) - [Addresses](07-addresses.md)
- [Addresses](08-addresses.md) - [Proof of Work](08-proof-of-work.md)
- [Proof of Work](09-proof-of-work.md) - [Filters](09-filters.md)
- [Filters](10-filters.md)
## Domain ## Domain
- [Relay Selections](11-relay-selections.md) - [Relay Selections](10-relay-selections.md)
- [Relay Metadata](12-relay-metadata.md) - [Relay Metadata](11-relay-metadata.md)
- [Relay Membership](13-relay-membership.md) - [Relay Membership](12-relay-membership.md)
- [Profiles](14-profiles.md) - [Profiles](13-profiles.md)
- [Follows](15-follows.md) - [Follows](14-follows.md)
- [Microblogging](16-microblogging.md) - [Microblogging](15-microblogging.md)
- [Reactions](17-reactions.md) - [Reactions](16-reactions.md)
- [Reports](18-reports.md) - [Reports](17-reports.md)
- [Emojis](19-emojis.md) - [Emojis](18-emojis.md)
- [Zaps](20-zaps.md) - [Zaps](19-zaps.md)
- [Rooms](21-rooms.md) - [Rooms](20-rooms.md)
## Networking ## Networking
- [Relay Connections](22-relay-connections.md) - [Relay Connections](21-relay-connections.md)
- [Relay Authentication](23-relay-authentication.md) - [Relay Authentication](22-relay-authentication.md)
- [Relay Policies](24-relay-policies.md) - [Relay Policies](23-relay-policies.md)
- [Server Authentication](25-server-authentication.md) - [Server Authentication](24-server-authentication.md)
- [Relay Management API](26-relay-management-api.md) - [Relay Management API](25-relay-management-api.md)
- [Blossom Media Storage](27-blossom-media-storage.md) - [Blossom Media Storage](26-blossom-media-storage.md)
## Signers ## Signers
- [Signer Interface](28-signer-interface.md) - [Signer Interface](27-signer-interface.md)
- [Secret Signers](29-secret-signers.md) - [Secret Signers](28-secret-signers.md)
- [Remote Signers](30-remote-signers.md) - [Remote Signers](29-remote-signers.md)
- [Android Signers](31-android-signers.md) - [Android Signers](30-android-signers.md)
- [Browser Signers](32-browser-signers.md) - [Browser Signers](31-browser-signers.md)
## Content ## Content
- [Entities](33-entities.md) - [Entities](32-entities.md)
- [Relays](34-relays.md) - [Relays](33-relays.md)
- [Rooms](35-rooms.md) - [Rooms](34-rooms.md)
- [Links](36-links.md) - [Links](35-links.md)
- [Lightning](37-lightning.md) - [Lightning](36-lightning.md)
- [Cashu](38-cashu.md) - [Cashu](37-cashu.md)
- [Emojis](39-emojis.md) - [Emojis](38-emojis.md)
- [Topics](40-topics.md) - [Topics](39-topics.md)
- [Code](41-code.md) - [Code](40-code.md)
## Storage ## Storage
- [Event Repository](42-event-repository.md) - [Event Repository](41-event-repository.md)
- [In Memory Backend](43-in-memory-backend.md) - [In Memory Backend](42-in-memory-backend.md)
- [Sqlite Backend](44-sqlite-backend.md) - [Sqlite Backend](43-sqlite-backend.md)
+62
View File
@@ -0,0 +1,62 @@
# Plan: Addresses
## Overview
This chapter introduces the `Address` struct — a stable reference to a replaceable or
addressable event. Where an event ID is a hash of a specific version, an address is the
triple `(kind, pubkey, identifier)` that names the *slot* a mutable event occupies.
## Prerequisites
Reader has already covered: keys (PublicKey, bech32), tags (Tags, d-tag access via
`tags.value("d")`), events (Event struct), kinds (kind ranges, is_addressable,
is_replaceable).
## Structure
### 1. Motivation (prose only)
- Event IDs are hashes of content — they change when the content changes
- For replaceable/addressable events, you need a stable reference
- An address is `kind:pubkey:identifier` — it names the slot, not the version
### 2. The module
- Register `pub mod addresses;` in lib.rs
- Import dependencies: keys::PublicKey, tags::Tag, events::Event, kinds, bech32, std::fmt, std::str::FromStr
### 3. Error type
- `AddressError` enum: InvalidFormat, InvalidBech32, WrongPrefix, InvalidKey, FieldMissing(&'static str), TlvError
### 4. The `Address` struct
- Fields: `kind: u16`, `pubkey: PublicKey`, `identifier: String`
- Constructor: `Address::new(kind, pubkey, identifier)`
- `from_event(event) -> Option<Self>` — returns None for non-replaceable/non-addressable events. Uses `tags.value("d").unwrap_or("").to_string()` for identifier.
- `to_tag(relay_hint: Option<&str>) -> Tag` — produces an `a` tag
### 5. Display / FromStr (kind:pubkey:identifier format)
- `Display`: `write!(f, "{}:{}:{}", self.kind, self.pubkey, self.identifier)`
- `FromStr`: split on `:`, take first two segments, join remainder as identifier (handles colons in d-tags)
### 6. NIP-19 naddr encoding
- Introduce TLV format: type(1 byte) + length(1 byte) + value(N bytes)
- Constants: SPECIAL=0, RELAY=1, AUTHOR=2, KIND=3
- `to_naddr() -> String` — encode as bech32 with "naddr" prefix
- `from_naddr(s: &str) -> Result<(Self, Vec<String>), AddressError>` — returns address + relay hints
- TLV decode: while loop consuming bytes
- SPECIAL=identifier (UTF-8), RELAY=URL string, AUTHOR=32-byte pubkey, KIND=u32 big-endian
### 7. What's next
- Brief pointer to proof of work or filters chapter
## Code placement
- All code in `coracle-lib/src/addresses.rs`
- Module registered in `coracle-lib/src/lib.rs`
## Tests needed
- `Address::new` basic construction
- `from_event` for addressable event (returns Some)
- `from_event` for regular event (returns None)
- `from_event` for replaceable event (returns Some, empty identifier)
- Display round-trip through FromStr
- to_naddr / from_naddr round-trip
- FromStr with identifier containing colons
- to_tag produces correct `a` tag format
+103
View File
@@ -0,0 +1,103 @@
# Research: Addresses
## Topic Summary
Create an `Address` struct representing a nostr event address (`kind:pubkey:d-tag`). It should:
- Be constructable from an event via `from_event`
- Encode/decode to the `naddr` NIP-19 bech32 format (TLV-based)
- Encode/decode to the `kind:pubkey:identifier` string format
- Provide convenient property access (kind, pubkey, identifier)
The reader already understands kind ranges and addressable events from the kinds chapter.
## Philosophy
From `ref/building-nostr`:
- Events are content-addressable via their ID (hash), giving referential transparency. But replaceability broke this — a replaceable event's ID changes on every update, so you need a stable reference.
- An "address" is `kind:pubkey:identifier`, pointing to all events sharing the same kind, pubkey, and d-tag.
- The `a` tag carries this triple as its value, with an optional relay hint in position 2.
- Design principle: "you should have a slight bias towards regular, non-replaceable events because they don't break referential transparency."
- The address includes the author's pubkey, which enables outbox-model relay lookup — unlike a bare event ID.
## Reference Implementation Analysis
### applesauce
- No custom Address struct — re-exports `AddressPointer` from nostr-tools: `{ kind, pubkey, identifier, relays? }`
- Also has `AddressPointerWithoutD` for replaceable (non-addressable) events
- `createReplaceableAddress(kind, pubkey, identifier?)` concatenates with `:`
- `parseReplaceableAddress(str)` splits on `:`, handles identifiers containing colons by joining remaining parts
- `getAddressPointerForEvent(event, relays?)` extracts d-tag: `event.tags.find(t => t[0] === "d")?.[1] ?? ""`
- Delegates naddr encoding to nostr-tools
### ndk
- No dedicated Address type — handles addresses on NDKEvent directly
- `tagAddress()` returns `"${kind}:${pubkey}:${dTag ?? ""}"`
- `dTag` getter/setter on NDKEvent
- Auto-generates d-tag for param replaceable events if missing (random string)
- `filterFromId(id)` handles three input forms: raw address string, naddr bech32, or event ID
- Regex: `NIP33_A_REGEX = /^(\d+):([0-9A-Fa-f]+)(?::(.*))?$/`
- Delegates nip19 to nostr-tools
### nostr-gadgets
- No dedicated Address type — addresses are plain `kind:pubkey:d-tag` strings
- `isATag(input)` validates via regex: `/^\d+:[0-9a-f]{64}:[^:]+$/`
- Imports `AddressPointer` from nostr-tools when needed
- Inline parsing by splitting on `:`
- d-tag defaults to empty string
### nostrlib (Go)
- Type: `EntityPointer { PublicKey, Kind, Identifier, Relays []string }`
- `AsTagReference()`: `fmt.Sprintf("%d:%s:%s", ep.Kind, ep.PublicKey.Hex(), ep.Identifier)`
- `ParseAddrString(addr)`: splits on `:` (max 3 parts)
- `Tags.GetD()` returns first d tag value or ""
- `MatchesEvent` checks kind + pubkey + identifier
- NIP-19 TLV: identifier=TLV0, relays=TLV1, author=TLV2(32 bytes), kind=TLV3(u32 BE)
- No separate from_event — caller assembles EntityPointer
### nostr-tools
- `AddressPointer = { identifier: string, pubkey: string, kind: number, relays?: string[] }`
- naddr TLV: 0=identifier(UTF-8), 1=relay(multiple), 2=pubkey(32 bytes), 3=kind(u32 BE)
- Strict decode: TLV 0, 2, 3 required; relays default to []
- a tag parsed as `tag[1].split(':')`
### rust-nostr
- `Coordinate { kind: Kind, public_key: PublicKey, identifier: String }`
- `Nip19Coordinate { coordinate: Coordinate, relays: Vec<RelayUrl> }` (separate from core Coordinate)
- `from_kpi_format(str)` parses `kind:pubkey:d-tag` via split on `:`
- `parse(str)` tries kpi format, then bech32, then NIP-21 URI
- `Display` outputs `kind:pubkey:identifier`
- `FromStr` delegates to `parse()`
- `verify()` validates: replaceable must have empty identifier, addressable must have non-empty
- TLV encode: SPECIAL(0)=identifier, AUTHOR(2)=32-byte pubkey, KIND(3)=u32 BE, RELAY(1)=URL strings
- TLV decode: while loop consuming bytes, first occurrence wins for each field type
- `Coordinate` converts `Into<Tag>` and `Into<Filter>`
- `CoordinateBorrow<'a>` for zero-copy operations
### welshman
- `Address` class with `kind, pubkey, identifier, relays`
- `Address.from(str, relays?)` parses `kind:pubkey:identifier` via regex
- `Address.fromNaddr(naddr)` decodes via nostr-tools
- `Address.fromEvent(event, relays?)` extracts d-tag: `event.tags.find(t => t[0] === "d")?.[1] || ""`
- `toString()` returns `kind:pubkey:identifier`
- `toNaddr()` delegates to nostr-tools
- `getAddress(event)` shorthand
- `Address.isAddress(str)` regex test
## Common Patterns
1. **Three fields**: kind (u16), pubkey (32 bytes), identifier/d-tag (string). All implementations agree.
2. **String format**: `kind:pubkey:identifier` — universal, colon-separated, kind is decimal.
3. **NIP-19 TLV**: identifier=type 0, relay=type 1, author=type 2 (32 bytes), kind=type 3 (u32 big-endian).
4. **d-tag defaults to empty string**, not None/null — important for replaceable events that have no d-tag.
5. **from_event pattern**: extract d-tag from `tags.find("d")?.value() ?? ""`, combine with event.kind and event.pubkey.
6. **Relay hints are separate**: the core address is just the triple; relays are transport metadata. Most implementations keep them optional/separate.
7. **Parsing colon-split**: most implementations just split on `:` and take 3 parts. Some handle identifiers containing colons by joining remaining parts.
## Considerations for Our Implementation
- Keep the core `Address` struct to the three fields (kind, pubkey, identifier). Relay hints can be added later as a wrapper or in the naddr encoding methods.
- `from_event` should work on our existing `Event` type, using `tags.value("d")` which already exists.
- NIP-19 TLV encoding is new infrastructure — the keys chapter only used simple bech32 (no TLV). This chapter introduces the TLV pattern that will be reused for `nprofile` and `nevent` later.
- `Display`/`FromStr` should use the `kind:pubkey:identifier` format (the `a` tag value), since that's the most common string representation.
- Unlike rust-nostr, we don't need to validate that addressable kinds have non-empty identifiers — an address is just a reference triple, and empty identifiers are valid for replaceable events.
- The identifier field should handle colons in d-tags correctly during parsing.
+206
View File
@@ -0,0 +1,206 @@
use coracle_lib::addresses::{Address, AddressError};
use coracle_lib::events::EventContent;
use coracle_lib::keys::SecretKey;
use coracle_lib::tags::Tag;
fn fixed_secret() -> SecretKey {
let bytes: [u8; 32] = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32,
];
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
}
fn make_signed_event(kind: u16, tags: Vec<Tag>) -> coracle_lib::events::Event {
let sk = fixed_secret();
let pk = sk.public_key();
let hashed = EventContent::new("test", tags)
.kind(kind)
.stamp(1_700_000_000)
.own(pk)
.hash();
let sig = sk.sign(&hashed.id);
hashed.sign(sig)
}
// --- Construction ---
#[test]
fn new_basic() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "my-article");
assert_eq!(addr.kind, 30023);
assert_eq!(addr.pubkey, pk);
assert_eq!(addr.identifier, "my-article");
assert!(addr.hints.is_empty());
}
#[test]
fn hints_builder() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "my-article")
.hints(["wss://relay.example.com", "wss://other.relay"]);
assert_eq!(addr.hints.len(), 2);
assert_eq!(addr.hints[0], "wss://relay.example.com");
}
#[test]
fn equality_ignores_hints() {
let pk = fixed_secret().public_key();
let a = Address::new(30023, pk, "slug");
let b = Address::new(30023, pk, "slug").hints(["wss://relay.example.com"]);
assert_eq!(a, b);
}
// --- from_event ---
#[test]
fn from_event_addressable() {
let event = make_signed_event(30023, vec![Tag::new("d", ["my-article"])]);
let addr = Address::from_event(&event).unwrap();
assert_eq!(addr.kind, 30023);
assert_eq!(addr.identifier, "my-article");
assert_eq!(addr.pubkey, event.pubkey);
assert!(addr.hints.is_empty());
}
#[test]
fn from_event_replaceable_kind_0() {
let event = make_signed_event(0, vec![]);
let addr = Address::from_event(&event).unwrap();
assert_eq!(addr.kind, 0);
assert_eq!(addr.identifier, "");
}
#[test]
fn from_event_replaceable_kind_3() {
let event = make_signed_event(3, vec![]);
let addr = Address::from_event(&event).unwrap();
assert_eq!(addr.kind, 3);
assert_eq!(addr.identifier, "");
}
#[test]
fn from_event_replaceable_range() {
let event = make_signed_event(10002, vec![]);
let addr = Address::from_event(&event).unwrap();
assert_eq!(addr.kind, 10002);
assert_eq!(addr.identifier, "");
}
#[test]
fn from_event_replaceable_ignores_d_tag() {
let event = make_signed_event(0, vec![Tag::new("d", ["should-be-ignored"])]);
let addr = Address::from_event(&event).unwrap();
assert_eq!(addr.kind, 0);
assert_eq!(addr.identifier, "");
}
#[test]
fn from_event_regular_returns_none() {
let event = make_signed_event(1, vec![]);
assert!(Address::from_event(&event).is_none());
}
#[test]
fn from_event_ephemeral_returns_none() {
let event = make_signed_event(20000, vec![]);
assert!(Address::from_event(&event).is_none());
}
// --- Display / FromStr ---
#[test]
fn display_roundtrip() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "my-article");
let s = addr.to_string();
assert!(s.starts_with("30023:"));
assert!(s.ends_with(":my-article"));
let parsed: Address = s.parse().unwrap();
assert_eq!(parsed, addr);
}
#[test]
fn fromstr_empty_identifier() {
let pk = fixed_secret().public_key();
let addr = Address::new(0, pk, "");
let s = addr.to_string();
let parsed: Address = s.parse().unwrap();
assert_eq!(parsed.identifier, "");
assert_eq!(parsed, addr);
}
#[test]
fn fromstr_identifier_with_colons() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "has:colons:inside");
let s = addr.to_string();
let parsed: Address = s.parse().unwrap();
assert_eq!(parsed.identifier, "has:colons:inside");
assert_eq!(parsed, addr);
}
#[test]
fn fromstr_invalid() {
assert!("not-an-address".parse::<Address>().is_err());
assert!("123".parse::<Address>().is_err());
assert!("abc:def:ghi".parse::<Address>().is_err());
}
// --- naddr encoding ---
#[test]
fn naddr_roundtrip_no_relays() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "my-article");
let naddr = addr.to_naddr();
assert!(naddr.starts_with("naddr1"));
let decoded = Address::from_naddr(&naddr).unwrap();
assert_eq!(decoded, addr);
assert!(decoded.hints.is_empty());
}
#[test]
fn naddr_roundtrip_with_relays() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "my-article")
.hints(["wss://relay.example.com", "wss://other.relay"]);
let naddr = addr.to_naddr();
let decoded = Address::from_naddr(&naddr).unwrap();
assert_eq!(decoded, addr);
assert_eq!(
decoded.hints,
vec!["wss://relay.example.com", "wss://other.relay"]
);
}
#[test]
fn naddr_roundtrip_empty_identifier() {
let pk = fixed_secret().public_key();
let addr = Address::new(10002, pk, "");
let naddr = addr.to_naddr();
let decoded = Address::from_naddr(&naddr).unwrap();
assert_eq!(decoded, addr);
}
#[test]
fn fromstr_accepts_naddr() {
let pk = fixed_secret().public_key();
let addr = Address::new(30023, pk, "my-article")
.hints(["wss://relay.example.com"]);
let naddr = addr.to_naddr();
let parsed: Address = naddr.parse().unwrap();
assert_eq!(parsed, addr);
assert_eq!(parsed.hints, vec!["wss://relay.example.com"]);
}
#[test]
fn from_naddr_wrong_prefix() {
let pk = fixed_secret().public_key();
let npub = pk.to_npub();
let err = Address::from_naddr(&npub).unwrap_err();
assert!(matches!(err, AddressError::WrongPrefix { .. }));
}