diff --git a/book/02-keys.md b/book/02-keys.md index 31986f4..560d5f8 100644 --- a/book/02-keys.md +++ b/book/02-keys.md @@ -133,6 +133,13 @@ impl PublicKey { .map_err(|_| KeyError::InvalidKey)?; Ok(PublicKey(inner)) } + + /// Parse from raw bytes (exactly 32). + pub fn from_bytes(bytes: &[u8]) -> Result { + let inner = secp256k1::XOnlyPublicKey::from_slice(bytes) + .map_err(|_| KeyError::InvalidKey)?; + Ok(PublicKey(inner)) + } } ``` diff --git a/book/06-kinds.md b/book/06-kinds.md index aa6693e..37cf05a 100644 --- a/book/06-kinds.md +++ b/book/06-kinds.md @@ -267,6 +267,6 @@ define a struct, implement `Kind`, and the trait provides the rest. ## What's next -The next chapter looks more closely at kind ranges — the system that -ties storage behavior to kind numbers — and what that means for relay -queries and event addressing. +The next chapter introduces addresses — the stable `kind:pubkey:identifier` +references that let you point at a replaceable or addressable event without +tying yourself to a particular version's id. diff --git a/book/07-addresses.md b/book/07-addresses.md new file mode 100644 index 0000000..12d38cd --- /dev/null +++ b/book/07-addresses.md @@ -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 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 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, +} + +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(&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) -> Self { + Address { + kind, + pubkey, + identifier: identifier.into(), + hints: Vec::new(), + } + } + + /// Attach relay hints to this address. + pub fn hints(mut self, relays: I) -> Self + where + I: IntoIterator, + S: Into, + { + 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 { + 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 { + // 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::(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 { + 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 = None; + let mut pubkey: Option = None; + let mut kind: Option = None; + let mut hints: Vec = 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. diff --git a/book/SUMMARY.md b/book/SUMMARY.md index 42b288d..b805c14 100644 --- a/book/SUMMARY.md +++ b/book/SUMMARY.md @@ -9,56 +9,55 @@ - [Tags](04-tags.md) - [Events](05-events.md) - [Kinds](06-kinds.md) -- [Kind Ranges](07-kind-ranges.md) -- [Addresses](08-addresses.md) -- [Proof of Work](09-proof-of-work.md) -- [Filters](10-filters.md) +- [Addresses](07-addresses.md) +- [Proof of Work](08-proof-of-work.md) +- [Filters](09-filters.md) ## Domain -- [Relay Selections](11-relay-selections.md) -- [Relay Metadata](12-relay-metadata.md) -- [Relay Membership](13-relay-membership.md) -- [Profiles](14-profiles.md) -- [Follows](15-follows.md) -- [Microblogging](16-microblogging.md) -- [Reactions](17-reactions.md) -- [Reports](18-reports.md) -- [Emojis](19-emojis.md) -- [Zaps](20-zaps.md) -- [Rooms](21-rooms.md) +- [Relay Selections](10-relay-selections.md) +- [Relay Metadata](11-relay-metadata.md) +- [Relay Membership](12-relay-membership.md) +- [Profiles](13-profiles.md) +- [Follows](14-follows.md) +- [Microblogging](15-microblogging.md) +- [Reactions](16-reactions.md) +- [Reports](17-reports.md) +- [Emojis](18-emojis.md) +- [Zaps](19-zaps.md) +- [Rooms](20-rooms.md) ## Networking -- [Relay Connections](22-relay-connections.md) -- [Relay Authentication](23-relay-authentication.md) -- [Relay Policies](24-relay-policies.md) -- [Server Authentication](25-server-authentication.md) -- [Relay Management API](26-relay-management-api.md) -- [Blossom Media Storage](27-blossom-media-storage.md) +- [Relay Connections](21-relay-connections.md) +- [Relay Authentication](22-relay-authentication.md) +- [Relay Policies](23-relay-policies.md) +- [Server Authentication](24-server-authentication.md) +- [Relay Management API](25-relay-management-api.md) +- [Blossom Media Storage](26-blossom-media-storage.md) ## Signers -- [Signer Interface](28-signer-interface.md) -- [Secret Signers](29-secret-signers.md) -- [Remote Signers](30-remote-signers.md) -- [Android Signers](31-android-signers.md) -- [Browser Signers](32-browser-signers.md) +- [Signer Interface](27-signer-interface.md) +- [Secret Signers](28-secret-signers.md) +- [Remote Signers](29-remote-signers.md) +- [Android Signers](30-android-signers.md) +- [Browser Signers](31-browser-signers.md) ## Content -- [Entities](33-entities.md) -- [Relays](34-relays.md) -- [Rooms](35-rooms.md) -- [Links](36-links.md) -- [Lightning](37-lightning.md) -- [Cashu](38-cashu.md) -- [Emojis](39-emojis.md) -- [Topics](40-topics.md) -- [Code](41-code.md) +- [Entities](32-entities.md) +- [Relays](33-relays.md) +- [Rooms](34-rooms.md) +- [Links](35-links.md) +- [Lightning](36-lightning.md) +- [Cashu](37-cashu.md) +- [Emojis](38-emojis.md) +- [Topics](39-topics.md) +- [Code](40-code.md) ## Storage -- [Event Repository](42-event-repository.md) -- [In Memory Backend](43-in-memory-backend.md) -- [Sqlite Backend](44-sqlite-backend.md) +- [Event Repository](41-event-repository.md) +- [In Memory Backend](42-in-memory-backend.md) +- [Sqlite Backend](43-sqlite-backend.md) diff --git a/book/plan/addresses.md b/book/plan/addresses.md new file mode 100644 index 0000000..1a7aaeb --- /dev/null +++ b/book/plan/addresses.md @@ -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` — 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), 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 diff --git a/book/research/addresses.md b/book/research/addresses.md new file mode 100644 index 0000000..5fb78cf --- /dev/null +++ b/book/research/addresses.md @@ -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 }` (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` and `Into` +- `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. diff --git a/coracle-lib/tests/addresses.rs b/coracle-lib/tests/addresses.rs new file mode 100644 index 0000000..b6a8fbb --- /dev/null +++ b/coracle-lib/tests/addresses.rs @@ -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) -> 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::
().is_err()); + assert!("123".parse::
().is_err()); + assert!("abc:def:ghi".parse::
().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 { .. })); +} +