spec: bech32-with-HRP user-facing identifiers#217
Conversation
intendednull
left a comment
There was a problem hiding this comment.
Calibrated review pass against NIP-19 and BIP-350, plus the cited Willow files (crates/identity/src/lib.rs, crates/state/src/hash.rs, crates/client/src/invite.rs, docs/specs/2026-03-27-shareable-join-links-design.md).
Strong points: the bech32m-everywhere argument is technically correct (BIP-350's q-before-p insertion bug really does bite variable-length TLV bodies), the Special/Relay/Author TLV layout is faithful to NIP-19, and the wire-vs-display boundary is drawn in the right place.
Issues posted inline, in roughly descending severity:
EndpointIdis apub usefromiroh_base— we can't change itsDisplayor add aFromStr(orphan rules).winvonly modelsJoinToken(pointer); the encrypted-channel-keysInvitePayloadis silently unaddressed.- bech32m-everywhere choice doesn't engage with the cross-tooling cost for
wpeer/npub. - HRPs like
wpeer/wblobhave no namespace claim — generic enough to collide. - TLV type 3 (
Kind) borrows the NIP-19 number for an incompatible concept. willow-idsas a leaf crate doesn't actually stay leaf-shaped once it depends onEndpointIdandEventHash.- Migration step 3 (
base64-looking → legacy invite/join) is ambiguous — two legacy base64 formats with no in-band tag. - Open question 3 (
wserver = genesis hash vs owner id) contradicts the HRP table; pick one.
Generated by Claude Code
| 4. Otherwise: reject. | ||
|
|
||
| `EndpointId::Display` and `EventHash::Display` switch to emit | ||
| `wpeer1…` / `wevent1…` immediately; hex stays accepted on input. |
There was a problem hiding this comment.
Blocker — EndpointId is a foreign re-export, not a Willow-owned type.
crates/identity/src/lib.rs:39 does pub use iroh_base::{EndpointId, ...};. We can't change Display or add a FromStr impl on EndpointId (orphan rules + we don't own the type). The same goes for the wblob HRP, which the spec says wraps an iroh-blobs content hash.
The spec needs to say which of the following it picks:
- Newtype wrapper (
pub struct PeerId(EndpointId)) with our ownDisplay/FromStr— touches every call-site that currently usesEndpointIddirectly (lots of files: invite payloads,UserProfile.peer_id, gossip envelopes). - Free functions only (
willow_ids::encode_peer(&id)) — keepEndpointId's upstreamDisplayas hex, never callformat!("{id}")in user-visible code, audit every existingDisplay/to_stringuse.
Option 2 is much less disruptive but it directly contradicts "EndpointId::Display switches to emit wpeer1… immediately." Pick one and document the migration cost. (EventHash is ours, so swapping its Display is fine.)
Generated by Claude Code
| | `EventHash` (SHA-256 digest) | 64-char lowercase hex | `crates/state/src/hash.rs:52` | | ||
| | `TopicId` (blake3 of topic name) | never displayed; internal | `crates/network/src/topics.rs:12` | | ||
| | Invite code | base64 of `pack()`ed `Invite` struct | `crates/client/src/invite.rs:99` | | ||
| | Join link | base64 `JoinToken` in URL fragment | `docs/specs/2026-03-27-shareable-join-links-design.md:42` | |
There was a problem hiding this comment.
The motivation table conflates two distinct invite formats, and winv only covers one of them.
The table lists "Invite code" (base64 pack()ed Invite) and "Join link" (base64 JoinToken) as if they're the same shape, but they aren't:
crates/client/src/invite.rs::InvitePayload(line 31) carriesgenesis_author,sync_providers, and aVec<EncryptedChannel>— i.e. per-recipient X25519-encrypted channel keys. It is a self-contained credential, not a pointer.JoinToken(docs/specs/2026-03-27-shareable-join-links-design.md:33) is a pointer withlink_id,server_id,inviter_peer_id, and human-readable strings, used to bootstrap a live P2P key exchange.
The "Concrete encodings" section defines winv only as the pointer (16-byte link_id + relay/author/expiry hints). That's fine for JoinToken, but it leaves the encrypted-channel-keys invite format unaddressed. Either:
- explicitly scope
winvto join-link pointers and say the encrypted invite blob remains base64 (it's a wire-format payload that contains ciphertext — bech32m TLV would 5× its length), or - add a separate HRP (e.g.
winvk) for the encrypted-keys variant.
As written, "Replaces the base64-packed JoinToken" is true but readers will (incorrectly) assume winv also replaces the encrypted invite blob.
Generated by Claude Code
| Nostr chose plain bech32 before TLV variants were common and cannot | ||
| migrate without a flag day. Willow is pre-1.0; picking bech32m now | ||
| avoids the trap. Every HRP uses bech32m — consistency beats | ||
| micro-optimisation. |
There was a problem hiding this comment.
bech32m-everywhere: reasoning is sound; please address the cross-ecosystem-tooling counter-argument explicitly.
The BIP-350 rationale is right — variable-length TLV bodies (wevent, wchan, winv, wrelay) are exactly the case where bech32's q-before-p insertion bug bites, so bech32m is the correct call there.
But the spec waves at "consistency beats micro-optimisation" without engaging with the actual interop cost: a 32-byte wpeer is dimensionally identical to npub1…, and there's a real ecosystem of Nostr keychain tools, key-format converters, and clipboard handlers that already understand plain bech32 of a 32-byte payload. Picking bech32m for wpeer means none of those tools work even as a copy-paste convenience.
Open question 1 already flags this. Suggest folding the answer into the section: either commit ("we don't expect Willow keys to round-trip through Nostr tooling, accept the loss") or add a per-HRP variant override and own the footgun. Don't leave it unresolved in a design spec.
Generated by Claude Code
| **HRP-length bikeshed.** 4-char forms (`wpub`, `wsrv`, `wev`) are | ||
| ~20% shorter but harder to scan. We pick 5–7 char forms because the | ||
| 32-byte body already dominates length and longer HRPs read cleanly | ||
| in logs ("that's a `wserver`"). |
There was a problem hiding this comment.
HRP collisions / namespace claim.
"Willow reserves the w prefix space" is a unilateral declaration with no citation to a registry. A few real conflicts to be aware of:
wpeeris generic enough that another P2P project (libp2p extensions, Veilid, etc.) could plausibly land on it.wblobcollides conceptually withiroh-blobs's own future identifiers if iroh ever adds a bech32 form.- bech32 explicitly contemplates an HRP registry (BIP-173 §"Registered human-readable parts"); Nostr also published its NIPs as a de facto registry. Willow has neither.
Lowest-risk fix: pick a clearly-Willow prefix (wlw…, wil…, or even just willow…) — yes, longer, but the 32-byte payload still dominates. Or at minimum add a sentence noting that these HRPs are project-internal and no cross-app interop is implied.
Generated by Claude Code
| | 3 | `Kind` | big-endian `u32` event kind discriminator | No | | ||
| | 4 | `Server` | 32-byte `wserver` id the entity lives under | No | | ||
| | 5 | `Channel` | blake3 channel id within a server | No | | ||
| | 6 | `ExpiresAt` | big-endian `u64` ms-since-epoch | No | |
There was a problem hiding this comment.
TLV scheme: type 3 (Kind) deviates from NIP-19 in a way the spec doesn't acknowledge.
NIP-19 type 3 (kind) is a 32-bit big-endian Nostr event-kind discriminator (1 = short text note, 30023 = long-form, etc.). Willow has no equivalent concept — events are an EventKind enum (crates/state/src/event.rs, ~17 variants) on the Willow side, completely orthogonal to Nostr's kind space. Calling it Kind and giving it the same TLV number invites confusion: a reviewer who knows NIP-19 will assume the values overlap; a Willow developer will pick a u32 that happens to collide with EventKind::Message discriminator.
Either:
- drop type 3 and reserve the slot (we don't actually need
Kindin any of the listedwinv/wevent/wchanuse cases the spec gives), or - rename it (
WillowKind) and document the explicit mapping toEventKinddiscriminants — including a stability promise, sinceEventKinddiscriminants are currently an internal Rust enum that can be reordered freely.
Other TLV types (0/1/2 = special/relay/author) line up faithfully with NIP-19, which is good.
Generated by Claude Code
| WASM-clean) and nothing else in the Willow graph. A dedicated crate | ||
| — rather than extending `willow-identity` — keeps the arrow one-way: | ||
| `identity`, `state`, `messaging`, `network` all depend on | ||
| `willow-ids`, never the reverse. |
There was a problem hiding this comment.
Push back on the new willow-ids leaf crate.
The justification ("keeps the arrow one-way: identity/state/messaging/network all depend on willow-ids") doesn't actually work as written:
encode_peer(&EndpointId)requireswillow-idsto depend oniroh-base(whereEndpointIdlives) — that's a real dep, not zero.encode_event(&EventHash, &EventHints)requireswillow-idsto depend onwillow-state(or forEventHashto move out ofwillow-state).EventHintswill need to referenceEndpointId(Author TLV),EventHash(Server TLV), and a channel id — pulling inwillow-stateagain.
Once you pull in iroh-base + willow-state, the "leaf crate with one dep" framing collapses. Realistic options:
- Put encode/decode in
willow-identityforwpeer, inwillow-stateforwevent/wserver/wchan, with thebech32dep added to each. No new crate, no circular dep risk (these crates already sit at the right level). - Make
willow-idstruly typeless: it works on&[u8; 32]+ raw HRP strings, and each consumer crate wraps it in typed helpers. Loses the type safety the API surface advertises but keeps the dep graph honest.
Option 1 is the lower-friction path; the spec should justify why it's not chosen.
Generated by Claude Code
| 1. Starts with a known Willow HRP followed by `1` → bech32m decode. | ||
| 2. Pure hex, length 64 → legacy `EndpointId` / `EventHash`. | ||
| 3. Base64-looking → legacy invite / join token. | ||
| 4. Otherwise: reject. |
There was a problem hiding this comment.
Migration heuristic in step 3 (Base64-looking → legacy invite / join token) is genuinely ambiguous.
The two legacy formats both decode through crates/client/base64:
InvitePayload(encrypted channel keys) — long, contains ciphertext.JoinToken(pointer with strings) — short.
A pasted base64 blob has no in-band marker telling them apart. Today there's no ambiguity because they're entered in different UI fields. Once both are handled by a single paste-detector (the spec implies this with sniff and the join-page parser), step 3 fans out into "try unpack::<InvitePayload>, on error try unpack::<JoinToken>" — bincode will happily produce structurally-valid garbage from the wrong type, so this isn't a clean fallback.
Suggest: tighten step 3 to "base64 of pack()ed InvitePayload" (the only legacy format the encrypted invite UI accepts) and explicitly state that JoinToken base64 is not in the legacy fallback path — joiners with old links will get a clean "this link format is no longer supported" message. Alternatively, make the step 3 dispatch explicit per-surface (settings page only tries InvitePayload, join page only tries JoinToken).
Generated by Claude Code
|
|
||
| 1. **bech32m everywhere, or plain bech32 on `wpeer` for Nostr-style | ||
| interop?** For: reuses `npub1…` tooling. Against: mixing variants | ||
| per HRP is a footgun for implementers. |
There was a problem hiding this comment.
Open question 3 (wserver = genesis EventHash vs owner EndpointId) needs to be closed before this ships.
This isn't a bikeshed — it's load-bearing. The HRP table on line 72 already commits to "genesis EventHash of a server" but the open-questions section then leaves it open. A reader can't tell whether the spec is making the decision or deferring it.
Pick one in the spec:
- Genesis EventHash: stable across owner-rotation, deterministic, the
ServerState's natural identity (matches theStateHashdesign referenced in CLAUDE.md). Downside: not knowable until the genesis event is created — the inviter has it, the joiner gets it from the link, fine in practice. - Owner EndpointId: known up front but breaks if ownership transfers and is conceptually wrong (a server is its DAG, not its current owner).
Genesis-hash is the right answer; commit to it and remove from open questions, or move the table row to "TBD."
Generated by Claude Code
intendednull
left a comment
There was a problem hiding this comment.
Solid scope and a tight non-goals section. The display-vs-wire boundary is the right call and Nostr's lesson is well-applied. Comments below focus on the open questions and a few load-bearing details that need to land before this graduates from spec to plan.
Strengths
- Display-only boundary is correct. Keeping bincode on the wire and bech32 strictly at the four UI surfaces (settings, join page, copy-id, log
Display) avoids the trap NIP-19 explicitly avoids in NIP-01. The four-surface table on lines 119–127 is the cleanest part of the spec. sniff()is the unsung hero. Letting paste handlers say "that looks like a peer id, not an invite" before doing crypto work is a real UX win and a meaningful security property (no oracle for "what kind of secret did you paste?").- Honest about the q/p flaw in BIP-173 and the rationale for bech32m is technically sound — variable-length TLV is exactly the case BIP-350 was designed for.
- Storage stays raw bytes (line 192). Easy to overlook; spec gets it right. Indexing
wpeer1...strings in SQLite would have been a disaster. - Forward-compat slot via TLV
Unknown-type ignore(line 89, 206) is the right default for a pre-1.0 format.
Concerns
-
bech32m vs bech32 trade-off is under-argued. The spec correctly identifies the technical reason for bech32m but dismisses cross-ecosystem pasteability with "consistency beats micro-optimisation." That's not what the trade is. The trade is: a user who pastes
npub1...into Willow getsInvalidChecksuminstead of a useful "this is a Nostr key, not a Willow id" error. Inline comment on line 62 proposes either picking bech32m and makingsniff()recognize Nostr HRPs explicitly, or splitting variants per HRP. Pick one; the current text dodges. -
wserverpayload is undefined and it matters everywhere. Open question #3 (line 224) is load-bearing forwevent,wchan, andwinv(all of which carry aServerTLV per line 97). My read: genesisEventHashis the right answer, full stop. Reasons:- Owner
EndpointIdis mutable in spirit — owners get compromised, hand off, change keys. The currentPermissionmodel (crates/state/src/event.rs:21) already separatesPermission::SyncProviderfrom ownership. - Genesis hash is content-addressed and matches
willow-state's existing identity model (crates/state/src/hash.rs:52). - "Both" creates a composition trap: which is canonical when they disagree? Don't go there.
- The "unknown until the server exists" objection is a non-issue — you don't need a
wserverid before there's a server.
Spec should commit to genesis hash and delete option (a) and (c).
- Owner
-
wsecretshould be a hard "no" in the spec, not an open question. Open question #4 (line 226) frames this as a debate; it isn't. The current spec already states "We do not introduce annsecequivalent" (line 44–46) — that's the correct stance. The open question contradicts it. Resolve in favor of the existing non-goal: secret keys live in OS keystore / mode-0600 files (crates/identity/src/lib.rs:64–73already enforces this), never on a clipboard. NIP-19'snsecis a known anti-pattern that has cost real Nostr users real money. Don't import the mistake. Backup/export flows should produce binary key files, not bech32 strings. -
TLV type space collision risk — see inline on line 99. Mirroring NIP-19 numbers 0–3 is fine; squatting on 4–6 is asking for a future flag day. Move Willow types to ≥16 or ≥128.
-
Composition with the existing share-link spec is unspecified — see inline on line 115.
JoinTokencarriesserver_nameandinviter_namefor pre-handshake UI rendering; this spec'swinvdoesn't accommodate them. Pick: drop the names (UX regression), add name TLVs (size cost), or run dual-format (defeats unification). -
willow-idscrate split is not justified by the API sketched. The proposed function signatures importEndpointIdandEventHash, sowillow-idscannot be a true leaf — it'd depend onwillow-identityandwillow-stateboth. See inline on line 140 — fold encoding into the owning crates, or makewillow-idsstrictly a[u8; N]+ HRP + TLV crate with no Willow type imports. The current proposal is the worst of both. -
Migration window has no exit criteria. "Two release cycles" (line 180) is hand-wavy when "release cycle" isn't a defined term for a pre-1.0 P2P app with no LTS. Suggest: (a) hex-on-input stays accepted indefinitely on
EndpointId::from_strbecause deleting it costs us nothing, (b) base64 invite/join-token decoders get a#[deprecated]attribute and a hard-removal date pinned to a specific git tag (e.g.v0.5.0).
Suggestions
-
HRP length: confirm
wpeer(5 chars) overwp(2). Disagree with optimizing the HRP. The body of an Ed25519 key is ~52 chars + 6 char checksum + HRP +1separator. Going fromwpeertowpsaves 3 chars on a ~62-char string (5%) and destroys the type signal in logs (wp1...vsws1...is unscannable at a glance). Spec recommendation is right; consider closing the open question. -
URL shape (open question #5):
willow://winv1...adds nothing for a web-only deployment (nowillow://registered handler unless we ship a desktop app — and per CLAUDE.md the Bevy app was removed). Bare bech32 in the fragment (https://willow.app/#go=winv1...) is fine, matches the existing join-link spec, and avoidsPermissions-Policywrangling for protocol handlers. Unless there's a desktop story coming back, drop the URL prefix. -
Add a
MalformedTlvtest that exercises the length byte specifically. Spec mentions it in the table (line 208). Worth being explicit that this includeslength=255with only 10 bytes of body, not just "length too small." -
Test vectors should be cross-checked against rust-bitcoin's
bech320.11 fixtures to guard against the dependency upgrade case the spec already worries about (line 203). A known-answer table generated from a different implementation would catch silent encoding drift. -
Consistency with PR #221 (
wrelay). That spec'sRelayEntry { url: String, role, capability, weight }carries a structured payload, not a bare URL. The HRP table here (line 76) listswrelaypayload as "relay endpoint URL" — that's narrower than what #221 needs. Reconcile: eitherwrelaycarries TLV withRelayUrl+Role+Capability(andRelayListevents embedwrelaystrings), orwrelayis URL-only andRelayListis the structured form. -
Consistency with PR #218 (sealed/gift-wrap DMs). Open question on that PR asks "do sealed events get a displayable id?" — answer here. My take: gift-wrap events get
weventlike any other (the metadata-privacy property is preserved by the gossip topic isolation, not by identifier opacity); seals never appear in UI so they don't need an HRP at all.
Overall: the design is sound, but four of the six open questions need to be resolved before this is implementable, and the willow-ids crate split needs a rethink. Happy to re-review once those land.
Generated by Claude Code
| Nostr chose plain bech32 before TLV variants were common and cannot | ||
| migrate without a flag day. Willow is pre-1.0; picking bech32m now | ||
| avoids the trap. Every HRP uses bech32m — consistency beats | ||
| micro-optimisation. |
There was a problem hiding this comment.
The reasoning here is technically correct (BIP-350 fixes a real bech32 flaw around q*p insertion/deletion on variable-length payloads — see BIP-350 motivation), but the conclusion deserves more scrutiny. The spec frames this as "consistency beats micro-optimization," but the actual cost being paid is cross-ecosystem pasteability: an npub1... and a wpeer1... will both start with a recognizable HRP yet use checksums that don't validate against each other's tooling. Concretely:
- A user pasting
npub1...into a Willow input expecting an HRP-tagged peer id won't get a useful error from a bech32m-only decoder — they'll getInvalidChecksum, which masks the real problem ("wrong ecosystem"). - Conversely, no Nostr-flavored library can sanity-check a
wpeer1...even at the checksum layer.
I'd push the spec to either:
- Pick bech32m and own the choice with a
sniff()that explicitly recognizesnpub/note/nprofileHRPs and returns aLooksLikeNostrerror variant — so the human sees a useful hint instead of "invalid checksum". - Or use bech32 for the simple fixed-length HRPs (
wpeer,wserver,wblob) where the q/p flaw is provably harmless (length check rejects malformed input), and bech32m only for the TLV ones (wevent,wchan,winv,wrelay). That's an implementer footgun (mixed variants per HRP) but it's the only way to getwpeer↔npubpasteability.
My vote: option 1. Mixing variants is worse than picking one and making the error path informative.
Generated by Claude Code
| | 3 | `Kind` | big-endian `u32` event kind discriminator | No | | ||
| | 4 | `Server` | 32-byte `wserver` id the entity lives under | No | | ||
| | 5 | `Channel` | blake3 channel id within a server | No | | ||
| | 6 | `ExpiresAt` | big-endian `u64` ms-since-epoch | No | |
There was a problem hiding this comment.
TLV type collision risk is real and under-discussed. Types 0–3 mirror NIP-19, but NIP-19 has historically added TLV types over time (e.g. kind was a later addition for naddr/nevent). If NIP-19 ever assigns a type 4, 5, or 6, two outcomes are possible:
- Same semantic (e.g. NIP-19 type 4 = "server context"): copying decoders across ecosystems works, lucky us.
- Different semantic (likely): a Nostr decoder fed a
wevent1...will silently accept aServerTLV as whatever NIP-19 assigned to type 4 and either crash or, worse, succeed with garbage.
The spec relies on HRP discrimination to prevent cross-decode, which is correct for in-ecosystem code but offers no protection if anyone writes a "universal NIP-19/Willow" decoder.
Two possible fixes:
- Skip the contested range entirely. Start Willow types at 16 (or 128). Cheap, removes ambiguity forever. The spec currently saves one byte per TLV by hugging 4–6 — that's not worth the migration risk.
- Add a versioning TLV (e.g. type 0xFF =
WillowSpecVersion) so future decoders can hard-reject mis-versioned strings.
I'd take #1. The "reviewer familiarity" benefit of types 0–3 doesn't carry over to types Willow invents — those are unfamiliar regardless of number.
Generated by Claude Code
| `Relay`, optional `ExpiresAt`, required `Author`. Replaces the | ||
| base64-packed `JoinToken`. | ||
| - `wchan1<var>` — TLV with `Server` + `Channel`; used in deep links | ||
| like `https://willow.app/#go=wchan1…`. |
There was a problem hiding this comment.
Composition with docs/specs/2026-03-27-shareable-join-links-design.md is unspecified and load-bearing. The current join-link spec defines JoinToken carrying inviter_peer_id, server_id, link_id, server_name, inviter_name — five fields, packed via willow_transport::pack(), base64-encoded into #join=.... This spec proposes winv1... carrying only "UUID link_id (16 B), ≥1 Relay, optional ExpiresAt, required Author."
What happens to server_name and inviter_name? Those are user-visible strings the JoinPage renders before the P2P handshake completes. Three options, all of which need to be called out:
- Drop them from the link → JoinPage shows a generic "You've been invited" until the live handshake returns metadata. This breaks the existing UX (line 120 of the join-link spec: "JoinPage shows: server name (from token), 'Invited by [name]'").
- Add
ServerNameandInviterNameTLV types → bloatswinvstrings considerably (UTF-8 names can be 64+ bytes each), pushes them well past the 90-char "fits in a tweet" sweet spot. - Make
winvstrictly the pointer and keep a parallel base64 metadata blob in the URL → defeats the whole point of unifying the format.
The spec needs to pick one and call out that this is a behavior change for the existing join flow (or explicitly preserve current behavior with a new TLV type — I'd lean toward option 2 with a "name TLV truncated to 64 bytes" rule, accepting longer strings).
Also: line 75 says winv "Replaces the base64-packed JoinToken." Replaces means breaking; the existing spec is already merged. Confirm this is intentional and add a migration path note.
Generated by Claude Code
| WASM-clean) and nothing else in the Willow graph. A dedicated crate | ||
| — rather than extending `willow-identity` — keeps the arrow one-way: | ||
| `identity`, `state`, `messaging`, `network` all depend on | ||
| `willow-ids`, never the reverse. |
There was a problem hiding this comment.
Pushing back on the willow-ids crate split. The justification given — "keeps the arrow one-way: identity, state, messaging, network all depend on willow-ids" — is upside-down. The proposed API in this section imports EndpointId (from willow-identity) and EventHash (from willow-state) for encode_peer / encode_event signatures. So willow-ids would need to either (a) depend on both upstream crates, immediately violating the "leaf crate" claim, or (b) re-define byte-array newtypes and force every caller to convert at the boundary, which is just deferred coupling with extra ceremony.
Two cleaner options:
- Put encode/decode in
willow-identity. It already ownsEndpointId(crates/identity/src/lib.rs:39) and is at the bottom of the dependency graph.EventHashlives inwillow-state— its bech32 helper goes there, no new crate needed. This matches the precedent ofEventHash::Displayalready living incrates/state/src/hash.rs:52. willow-idsas a pure-bytes crate that knows about HRPs and TLV but takes/returns[u8; 32]andVec<u8>. Then per-type wrappers (EndpointId::to_bech32(),EventHash::to_bech32()) live in their owning crates and call intowillow-ids. This is the only version that genuinely earns "leaf crate" status.
The current sketch reads like option 1 with extra steps. Pick (2) explicitly, or fold the logic into the existing crates.
Generated by Claude Code
intendednull
left a comment
There was a problem hiding this comment.
Round 2: comparative research
Round 1 covered Nostr NIP-19 and BIP-350 internals. This round looks at non-Nostr prior art for type-tagged identifiers and what Willow can borrow or skip.
1. Cosmos SDK is the most relevant precedent
Cosmos SDK uses bech32 with per-chain HRPs (cosmos1…, osmo1…, juno1…, celestia1…, stars1…). The same Ed25519/secp256k1 key material is rendered under different HRPs depending on which chain's UI is consuming it — exactly Willow's "same 32-byte EndpointId, but is it a wpeer or a wserver?" situation, just at ecosystem scale.
The lesson from Cosmos is operational: cross-chain phishing where a user pastes a cosmos1… into an osmo1… send field is a documented class of UX bug, and wallets like Keplr and Leap added explicit HRP-mismatch warnings ("this address is for the Cosmos Hub, not Osmosis") rather than a generic "invalid address". The spec's sniff() function gets this almost right, but its DecodeError enum should distinguish:
WrongHrp { expected: Hrp, got: Hrp }— known Willow HRP, wrong typeWrongEcosystem { detected_hrp: String }— looks like bech32m but HRP isn't ours (so the UI can say "that looks like a Nostr/Cosmos id, not a Willow one")InvalidChecksum— looks like ours but corrupted
Currently WrongHrp { expected, got: String } collapses cases 1 and 2, which is the Cosmos mistake.
2. Multibase is the alternative Willow could pick instead
Multiformats' multibase prepends a single self-describing code point (z=base58btc, b=base32, f=base16, etc.) so a decoder knows what alphabet to use. It is purely an encoding-disambiguation layer — it does not checksum, and offers no error detection of its own (it relies on the encoded payload's structure for integrity).
Tradeoff for Willow:
- bech32m: human-friendly + 6-char BCH checksum + HRP type tag, ~10% length overhead.
- multibase: more compact (1-char prefix), no checksum, no human-readable type tag.
For Willow's stated goal — paste-error prevention and type signal in UIs/logs — bech32m wins clearly. But the spec should briefly say "we considered multibase and rejected it because it doesn't checksum and the prefix isn't human-meaningful," so the rejection is on the record.
3. W3C DIDs are the closest standards-body analog
DIDs use did:method:method-specific-id (e.g. did:web:, did:key:, did:plc:, did:peer:). Two relevant lessons:
- The method registry is curated at W3C — over 100 experimental method specs exist, but the registry mechanism lets implementers know which are real. Willow's HRPs need at least an internal table-of-truth (the spec already has one), but if Willow ever wants third-party tooling to recognize
winv1…strings, this table needs to live in a stable location, not just in the spec doc. - DIDs are designed to be resolvable — a DID resolver returns a DID document. Willow's HRPs are one-way type tags only; that is a valid simpler choice, but the spec should explicitly say "Willow HRPs are type tags, not resolvable identifiers — there is no
wpeer1…→ metadata lookup protocol implied by this spec." Otherwise readers familiar with DIDs may assume there is.
4. HRP registration practice across ecosystems
| Ecosystem | Registry |
|---|---|
| Bitcoin | BIP-173 §"Registered HRPs" — informal but referenced |
| Cosmos | None — chains pick their own HRP, collisions handled socially |
| Nostr | None — npub/nsec/nevent/naddr are de-facto via NIP-19 only |
In practice, collisions are rare because consuming UIs are siloed (a Bitcoin wallet doesn't try to parse cosmos1…, a Nostr client doesn't try wpeer1…). The spec's claim that "Willow reserves the w prefix space" is therefore aspirational with no enforcement mechanism. Two options:
- Drop the reservation language and say "Willow uses
w*HRPs by convention; cross-ecosystem collisions are not actively prevented and are mitigated by the fact that decoders only accept the specific HRPs they expect." - Or downgrade to "Willow's known HRPs are
wpeer,wserver,wevent,wchan,winv,wrelay,wblob. Otherw*strings are not Willow."
Either is more honest than "reserves the space."
5. Real Nostr nsec incidents validate omitting wsecret
I couldn't pull a specific case study on a quick search, but the failure mode is well-known in the Nostr community: nsec1… strings are visually indistinguishable from npub1… to the casual eye (same length, same bech32 alphabet, only the HRP differs by two characters), and clipboard managers, screenshots, and bot mentions have all caused leaks. The asymmetry — npub is safe to share, nsec is catastrophic — combined with their visual similarity is the design flaw.
The spec's open question 4 ("wsecret/nsec-equivalent — current stance: off") should be closed in the affirmative: never. The cost of having a string form for private keys is unbounded; the benefit (key backup UX) can be served by other formats (encrypted file, QR with explicit "secret" framing, mnemonic) that don't share a visual namespace with public identifiers.
Suggested spec edits (minimal)
- Split
DecodeError::WrongHrpinto the three cases above. - Add a paragraph rejecting multibase with one-line reasoning.
- Add a "Not goals" line: "Willow HRPs are type tags, not resolvable identifiers."
- Soften "Willow reserves the
wprefix space" to "Willow usesw*HRPs by convention." - Close open question 4: no
wsecret, ever — and cite the visual-similarity attack class.
Generated by Claude Code
Apply review decisions to the relay capability document spec: - Promote signing to v1 MUST (inline signature, RFC 8785 JCS canonical bytes, signature field excluded from canonicalisation). - Specify dispatch surgery: explicit branch in dispatch_connection for /.well-known/willow plus OPTIONS preflight; reuse BOOTSTRAP_IO_TIMEOUT and MAX_CONCURRENT_BOOTSTRAP_CONNECTIONS; extend (not mirror) the handle_bootstrap_connection pattern. - Drop event_schema_range (no EVENT_SCHEMA_VERSION exists in willow-state); list as future work. - Resolve multi-tenant question: one shared doc per host, relay is topic-agnostic. - Soften operator-metadata leakage: version is coarse semver, software is project name, both MAY be omitted. - Two-tier caching by status: ok=300s, degraded/read_only=5s with must-revalidate. - Recommend WS clients also send Sec-WebSocket-Protocol; JSON is advisory pre-connect. - Fix port framing: relay binds one port multiplexing TCP+WS, not two. - Drop sync_provider_only (operator vibes without a concrete pre-handshake check). - Add Cross-spec coordination table pinning feature tags for #214, #216, #217, #218, #219, #220, #221. - Rewrite Open Questions to keep only genuinely-open items (paid-relay semantics, utilisation telemetry, relay discovery, feature registry). https://claude.ai/code/session_01XmbVXWnKTRVjPp9kmKRSBn
- Motivation table: clarify EndpointId Display=hex but FromStr also accepts base32-no-pad (upstream iroh-base behavior, not ours). - Motivation table: rename Invite -> InvitePayload, fix line cite to 98-99, mention willow_transport::pack free function. - Concrete encodings: correct ~62 -> ~64 char total for wpeer1 (HRP 5 + sep 1 + 52 data + 6 checksum = 64). - Boundary table: enumerate all real PeerId copy sites (settings.rs 64-68, 107-111, 253, 271; add_server.rs:223), flag the message "copy id" UI as new (today only "copy text" exists at message.rs:1353), drop the bogus settings.rs:69 cite. - Boundary closing paragraph + API surface: rework the EndpointId bech32 story. The orphan rule blocks both inherent impls and FromStr/Display impls on a foreign re-export, so EndpointId gets free functions (endpoint_id_to_bech32 / endpoint_id_from_bech32) plus a sealed extension trait; only EventHash, owned by willow-state, gets inherent methods + FromStr. Code example rewritten to match. - Migration table + decoder precedence + testing table: thread the base32-no-pad caveat through, fix Invite::pack -> InvitePayload, pin a passthrough test for the upstream FromStr behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of a set of 8 specs drawing lessons from Nostr's protocol and ecosystem. Use this PR to discuss the design — not proposing implementation, only the spec.
What & why
Nostr's NIP-19 uses bech32-with-HRP (
npub1...,nsec1...,note1...) as display-only identifiers so the HRP acts as a built-in type tag — users can't paste a secret key where an event id was expected. Willow currently uses raw hex / base64 for peer ids, invites, and event hashes; they're visually indistinguishable.This spec proposes moving all user-facing Willow identifiers to bech32m (BIP-350, chosen over plain bech32 for its better insertion/deletion error-detection on variable-length TLV payloads):
wpeerwserverweventwchanwinvwrelaywblobTLV types 0–3 mirror NIP-19 exactly for reviewer familiarity; 4–6 are Willow-specific. Encoding stays display-only — the wire format remains bincode. A new leaf crate
willow-idsowns encode/decode.Spec file:
docs/specs/2026-04-24-bech32-identifiers.mdOpen questions for review
wp,ws) vs readable (wpeer,wserver). Spec recommends readablewserverpayload shape — owner EndpointId, genesis event hash, or both?wsecretfor exporting secret keys, or keep those raw/never-printed?willow://winv1...vs justwinv1...in share linksComposition with sibling specs
wrelayHRP lives heredocs/specs/2026-03-27-shareable-join-links-design.md): interaction withwinvCommit is unsigned due to harness signing backend failure (same as sibling PRs in this set).
Generated by Claude Code