Add addresses chapter
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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<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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user