6.2 KiB
6.2 KiB
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
naddrNIP-19 bech32 format (TLV-based) - Encode/decode to the
kind:pubkey:identifierstring 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
atag 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
AddressPointerfrom nostr-tools:{ kind, pubkey, identifier, relays? } - Also has
AddressPointerWithoutDfor replaceable (non-addressable) events createReplaceableAddress(kind, pubkey, identifier?)concatenates with:parseReplaceableAddress(str)splits on:, handles identifiers containing colons by joining remaining partsgetAddressPointerForEvent(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 ?? ""}"dTaggetter/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-tagstrings isATag(input)validates via regex:/^\d+:[0-9a-f]{64}:[^:]+$/- Imports
AddressPointerfrom 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 ""MatchesEventchecks 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)parseskind:pubkey:d-tagvia split on:parse(str)tries kpi format, then bech32, then NIP-21 URIDisplayoutputskind:pubkey:identifierFromStrdelegates toparse()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
CoordinateconvertsInto<Tag>andInto<Filter>CoordinateBorrow<'a>for zero-copy operations
welshman
Addressclass withkind, pubkey, identifier, relaysAddress.from(str, relays?)parseskind:pubkey:identifiervia regexAddress.fromNaddr(naddr)decodes via nostr-toolsAddress.fromEvent(event, relays?)extracts d-tag:event.tags.find(t => t[0] === "d")?.[1] || ""toString()returnskind:pubkey:identifiertoNaddr()delegates to nostr-toolsgetAddress(event)shorthandAddress.isAddress(str)regex test
Common Patterns
- Three fields: kind (u16), pubkey (32 bytes), identifier/d-tag (string). All implementations agree.
- String format:
kind:pubkey:identifier— universal, colon-separated, kind is decimal. - NIP-19 TLV: identifier=type 0, relay=type 1, author=type 2 (32 bytes), kind=type 3 (u32 big-endian).
- d-tag defaults to empty string, not None/null — important for replaceable events that have no d-tag.
- from_event pattern: extract d-tag from
tags.find("d")?.value() ?? "", combine with event.kind and event.pubkey. - Relay hints are separate: the core address is just the triple; relays are transport metadata. Most implementations keep them optional/separate.
- 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
Addressstruct to the three fields (kind, pubkey, identifier). Relay hints can be added later as a wrapper or in the naddr encoding methods. from_eventshould work on our existingEventtype, usingtags.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
nprofileandneventlater. Display/FromStrshould use thekind:pubkey:identifierformat (theatag 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.