Skip to content

spec: bech32-with-HRP user-facing identifiers#217

Merged
intendednull merged 3 commits into
mainfrom
claude/spec-bech32-identifiers
Apr 26, 2026
Merged

spec: bech32-with-HRP user-facing identifiers#217
intendednull merged 3 commits into
mainfrom
claude/spec-bech32-identifiers

Conversation

@intendednull
Copy link
Copy Markdown
Owner

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):

HRP Payload
wpeer Ed25519 pubkey
wserver server id
wevent event hash
wchan channel ref within a server
winv invite (TLV-encoded)
wrelay relay URL reference
wblob blob hash

TLV 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-ids owns encode/decode.

Spec file: docs/specs/2026-04-24-bech32-identifiers.md

Open questions for review

  1. bech32 vs bech32m — spec picks bech32m; push back if you'd rather match NIP-19's bech32 choice exactly for cross-ecosystem pasteability
  2. HRP length — short (wp, ws) vs readable (wpeer, wserver). Spec recommends readable
  3. wserver payload shape — owner EndpointId, genesis event hash, or both?
  4. Do we need wsecret for exporting secret keys, or keep those raw/never-printed?
  5. URL shape: willow://winv1... vs just winv1... in share links
  6. Migration window — support old hex formats for how long?

Composition with sibling specs

  • Outbox relay discovery: wrelay HRP lives here
  • Seal + gift-wrap DMs: do sealed events get a displayable id?
  • Share links (existing spec docs/specs/2026-03-27-shareable-join-links-design.md): interaction with winv

Commit is unsigned due to harness signing backend failure (same as sibling PRs in this set).


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. EndpointId is a pub use from iroh_base — we can't change its Display or add a FromStr (orphan rules).
  2. winv only models JoinToken (pointer); the encrypted-channel-keys InvitePayload is silently unaddressed.
  3. bech32m-everywhere choice doesn't engage with the cross-tooling cost for wpeer / npub.
  4. HRPs like wpeer/wblob have no namespace claim — generic enough to collide.
  5. TLV type 3 (Kind) borrows the NIP-19 number for an incompatible concept.
  6. willow-ids as a leaf crate doesn't actually stay leaf-shaped once it depends on EndpointId and EventHash.
  7. Migration step 3 (base64-looking → legacy invite/join) is ambiguous — two legacy base64 formats with no in-band tag.
  8. 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Newtype wrapper (pub struct PeerId(EndpointId)) with our own Display/FromStr — touches every call-site that currently uses EndpointId directly (lots of files: invite payloads, UserProfile.peer_id, gossip envelopes).
  2. Free functions only (willow_ids::encode_peer(&id)) — keep EndpointId's upstream Display as hex, never call format!("{id}") in user-visible code, audit every existing Display/to_string use.

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` |
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) carries genesis_author, sync_providers, and a Vec<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 with link_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 winv to 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`").
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • wpeer is generic enough that another P2P project (libp2p extensions, Veilid, etc.) could plausibly land on it.
  • wblob collides conceptually with iroh-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 |
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Kind in any of the listed winv/wevent/wchan use cases the spec gives), or
  • rename it (WillowKind) and document the explicit mapping to EventKind discriminants — including a stability promise, since EventKind discriminants 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) requires willow-ids to depend on iroh-base (where EndpointId lives) — that's a real dep, not zero.
  • encode_event(&EventHash, &EventHints) requires willow-ids to depend on willow-state (or for EventHash to move out of willow-state).
  • EventHints will need to reference EndpointId (Author TLV), EventHash (Server TLV), and a channel id — pulling in willow-state again.

Once you pull in iroh-base + willow-state, the "leaf crate with one dep" framing collapses. Realistic options:

  1. Put encode/decode in willow-identity for wpeer, in willow-state for wevent/wserver/wchan, with the bech32 dep added to each. No new crate, no circular dep risk (these crates already sit at the right level).
  2. Make willow-ids truly 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the StateHash design 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

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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 gets InvalidChecksum instead of a useful "this is a Nostr key, not a Willow id" error. Inline comment on line 62 proposes either picking bech32m and making sniff() recognize Nostr HRPs explicitly, or splitting variants per HRP. Pick one; the current text dodges.

  2. wserver payload is undefined and it matters everywhere. Open question #3 (line 224) is load-bearing for wevent, wchan, and winv (all of which carry a Server TLV per line 97). My read: genesis EventHash is the right answer, full stop. Reasons:

    • Owner EndpointId is mutable in spirit — owners get compromised, hand off, change keys. The current Permission model (crates/state/src/event.rs:21) already separates Permission::SyncProvider from 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 wserver id before there's a server.

    Spec should commit to genesis hash and delete option (a) and (c).

  3. wsecret should 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 an nsec equivalent" (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–73 already enforces this), never on a clipboard. NIP-19's nsec is 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.

  4. 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.

  5. Composition with the existing share-link spec is unspecified — see inline on line 115. JoinToken carries server_name and inviter_name for pre-handshake UI rendering; this spec's winv doesn't accommodate them. Pick: drop the names (UX regression), add name TLVs (size cost), or run dual-format (defeats unification).

  6. willow-ids crate split is not justified by the API sketched. The proposed function signatures import EndpointId and EventHash, so willow-ids cannot be a true leaf — it'd depend on willow-identity and willow-state both. See inline on line 140 — fold encoding into the owning crates, or make willow-ids strictly a [u8; N] + HRP + TLV crate with no Willow type imports. The current proposal is the worst of both.

  7. 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_str because 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) over wp (2). Disagree with optimizing the HRP. The body of an Ed25519 key is ~52 chars + 6 char checksum + HRP + 1 separator. Going from wpeer to wp saves 3 chars on a ~62-char string (5%) and destroys the type signal in logs (wp1... vs ws1... 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 (no willow:// 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 avoids Permissions-Policy wrangling for protocol handlers. Unless there's a desktop story coming back, drop the URL prefix.

  • Add a MalformedTlv test that exercises the length byte specifically. Spec mentions it in the table (line 208). Worth being explicit that this includes length=255 with only 10 bytes of body, not just "length too small."

  • Test vectors should be cross-checked against rust-bitcoin's bech32 0.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's RelayEntry { url: String, role, capability, weight } carries a structured payload, not a bare URL. The HRP table here (line 76) lists wrelay payload as "relay endpoint URL" — that's narrower than what #221 needs. Reconcile: either wrelay carries TLV with RelayUrl + Role + Capability (and RelayList events embed wrelay strings), or wrelay is URL-only and RelayList is 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 wevent like 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 get InvalidChecksum, 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:

  1. Pick bech32m and own the choice with a sniff() that explicitly recognizes npub/note/nprofile HRPs and returns a LooksLikeNostr error variant — so the human sees a useful hint instead of "invalid checksum".
  2. 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 get wpeernpub pasteability.

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 |
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a Server TLV 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:

  1. 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.
  2. 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…`.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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]'").
  2. Add ServerName and InviterName TLV types → bloats winv strings considerably (UTF-8 names can be 64+ bytes each), pushes them well past the 90-char "fits in a tweet" sweet spot.
  3. Make winv strictly 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Put encode/decode in willow-identity. It already owns EndpointId (crates/identity/src/lib.rs:39) and is at the bottom of the dependency graph. EventHash lives in willow-state — its bech32 helper goes there, no new crate needed. This matches the precedent of EventHash::Display already living in crates/state/src/hash.rs:52.
  2. willow-ids as a pure-bytes crate that knows about HRPs and TLV but takes/returns [u8; 32] and Vec<u8>. Then per-type wrappers (EndpointId::to_bech32(), EventHash::to_bech32()) live in their owning crates and call into willow-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

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 type
  • WrongEcosystem { 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:

  1. 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.
  2. 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. Other w* 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)

  1. Split DecodeError::WrongHrp into the three cases above.
  2. Add a paragraph rejecting multibase with one-line reasoning.
  3. Add a "Not goals" line: "Willow HRPs are type tags, not resolvable identifiers."
  4. Soften "Willow reserves the w prefix space" to "Willow uses w* HRPs by convention."
  5. Close open question 4: no wsecret, ever — and cite the visual-similarity attack class.

Generated by Claude Code

intendednull pushed a commit that referenced this pull request Apr 25, 2026
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
claude and others added 2 commits April 25, 2026 07:13
- 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>
@intendednull intendednull merged commit 731e38e into main Apr 26, 2026
5 checks passed
@intendednull intendednull deleted the claude/spec-bech32-identifiers branch April 26, 2026 07:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants