Skip to content

nostr: add NIP-05 over Namecoin (.bit) module - #1367

Draft
mstrofnone wants to merge 2 commits into
nostrdevkit:masterfrom
mstrofnone:feat/nip05-namecoin
Draft

mstrofnone wants to merge 2 commits into
nostrdevkit:masterfrom
mstrofnone:feat/nip05-namecoin

Conversation

@mstrofnone

@mstrofnone mstrofnone commented May 21, 2026 •

Copy link
Copy Markdown

Adds a nip05namecoin sibling to nip05 that resolves NIP-05 identifiers rooted in the Namecoin blockchain instead of DNS, mirroring the existing nip05 module's no-transport shape so callers can chain the two without reshaping their code.

use nostr::nips::nip05namecoin;
use nostr::nips::nip05;

if nip05namecoin::is_valid_identifier(input) {
    // route through Namecoin (caller fetches the name value via ElectrumX
    // and hands the JSON to nip05namecoin::Nip05NamecoinProfile::from_raw_json)
} else {
    // existing DNS-based nip05::Nip05Profile::from_raw_json path
}

This is the Rust analog of the same module recently ported to several other ecosystems:

The parser shape, local-part priority (exact → _ → first valid), and JSON extraction rules match those implementations byte-for-byte.

Identifiers accepted

  • [email protected]
  • example.bit (uses the _ root entry)
  • d/example (Namecoin domain namespace)
  • id/alice (Namecoin identity namespace)
  • A leading nostr: NIP-21 prefix is tolerated.

Shape

I deliberately mirrored nip05.rs rather than shipping a transport. The existing nip05.rs module exposes:

  • Nip05Address::parse (identifier → URL to fetch)
  • Nip05Profile::from_raw_json (caller-fetched JSON → profile)
  • verify_from_raw_json

…and leaves the HTTP GET to integrators (the nostr-sdk Client, downstream apps, the reqwest-based example etc.). I followed the same convention here:

  • NamecoinAddress::parse (identifier → Namecoin name + local-part + ElectrumX scripthash)
  • Nip05NamecoinProfile::from_raw_json (caller-fetched JSON → profile)
  • verify_from_raw_json

Plus the Namecoin-specific glue callers need to drive ElectrumX themselves:

  • build_name_index_script(name) — the NAME_UPDATE-shaped script the ElectrumX fork indexes against
  • electrum_script_hash(script) — reverse(sha256(script)) hex, the format expected by blockchain.scripthash.get_history
  • parse_name_update_script(script) — decode the NAME_UPDATE output returned by blockchain.transaction.get and extract (name, value)
  • DEFAULT_ELECTRUMX_SERVERS — the public Namecoin ElectrumX endpoints, matching the Kotlin / Swift / Go references

This keeps the module no_std + alloc compatible (only serde_json and bitcoin_hashes::sha256, both already crate deps) and leaves transport choices (raw TCP+TLS, WSS, Tor, custom pinned certs) to the caller — same convention as the rest of the crate.

If a built-in async ElectrumX client is wanted later, the obvious shape is a sibling crate next to nostr-blossom rather than pulling tokio/networking into nostr. Happy to follow up with that if it's desired.

Value-extraction support

Both name-value shapes used by .bit NIP-05 in the wild are supported:

  • Simple: "nostr": "<64-hex pubkey>"
  • Extended (Amethyst / spec draft): "nostr": { "names": {"_": "...", "alice": "..."}, "relays": {"<pubkey>": ["wss://..."]} } for d/ names, and "nostr": { "pubkey": "...", "relays": ["wss://..."] } for id/ names.

extract_nostr_from_value is also exported so callers can inspect non-nostr fields on the same name value (bitcoin, lightning, http, etc.) without reparsing.

Import-chain support (ifa-0001 §import)

The 520-byte per-name limit on Namecoin makes apex records crowded, so ifa-0001 §"import" lets a name delegate shared blocks into a sibling name (typically dd/<name>) via an "import" key on the JSON value. The canonical demo target testls.bit uses exactly this pattern: the apex d/testls carries only "import":"dd/testls" plus a couple of small keys, and the actual nostr.names block lives at dd/testls. Without import handling, NIP-05 lookups against records like this silently fail.

This PR threads import expansion between the apex fetch and field extraction. New entry points alongside the existing API:

  • Nip05NamecoinProfile::from_json_with_lookup(addr, apex, lookup) / from_raw_json_with_lookup(addr, raw, lookup)
  • verify_from_json_with_lookup / verify_from_raw_json_with_lookup
  • expand_imports(&mut value, lookup, DEFAULT_IMPORT_DEPTH) for direct use

The lookup argument is a FnMut(&str) -> Option<String> returning the raw JSON value of an imported name (the caller drives ElectrumX, just like for the apex fetch). Behaviour follows ifa-0001:

  • Four import value shapes: bare string, ["d/foo"], ["d/foo","sel"], canonical array-of-arrays.
  • Subdomain selector walks the imported value's map tree in DNS order (rightmost label first) with exact-label > * > "" fallback.
  • Importer-wins merge, with null in the importer acting as a suppression marker.
  • Recursion up to the spec-minimum depth of 4; deeper chains are silently truncated and the importing record's own fields still apply.
  • Cycles are broken by a visited (name, selector) set.
  • Lookup failures (None, malformed JSON, unreachable name) are absorbed as {} so transient ElectrumX hiccups don't kill an otherwise resolvable record.
  • Records with no import key pay zero extra cost: the lookup closure is never invoked. Existing call sites that use from_raw_json continue to work unchanged.

Tests

46 unit tests under nip05namecoin::tests cover:

  • Identifier parser (all five shapes + casing + NIP-21 prefix + garbage rejection).
  • Both JSON value shapes (simple + extended), including the id/ / pubkey / relays path.
  • Local-part priority (exact → _ → first valid) for the extended form.
  • Malformed-input null paths.
  • NAME_UPDATE script builder + parser, including OP_PUSHDATA1 framing.
  • Round-trip Sha256 → reverse → hex → reverse → Sha256 for the scripthash.
  • Smoke check on DEFAULT_ELECTRUMX_SERVERS.
  • import expansion: all four shorthand shapes, importer-wins, null suppression, depth-4 happy path, depth-1 truncation, cycle break, multi-label selector descent, wildcard / empty-key fallback, malformed import value, lookup returning None / malformed JSON.
  • Integration mirroring testls.bit: bare resolution across import, named local-part across import, importer-wins on nostr.names, failed-import preserves local names, no-import record issues zero extra lookups, verify across imports.
$ cargo test -p nostr --lib nip05namecoin
running 46 tests
... test result: ok. 46 passed; 0 failed

$ cargo test -p nostr
running 290 tests
... test result: ok. 290 passed; 0 failed

$ cargo clippy -p nostr --no-deps --tests -- -D warnings
... Finished `dev` profile (no warnings)

Live verification

The scripthash this module produces for d/testls is b519574e96740a4b3627674a0708e71a73e654a95117fc828b8e177a0579ab42. A public Namecoin ElectrumX server (nmc2.bitcoins.sk:57002) recognises it — blockchain.scripthash.get_history against that scripthash returns the d/testls transaction history, with the latest tx hash (e26731fd1de3be8bf29dc48db5174e6bad6b625704f61ffe80287f2e3ebcde96, height 822885) matching the canonical namecoin-cli name_show d/testls. So the script-building and scripthash derivation are correct on real on-chain data, not just unit-test fixtures.

Public API

Signatures mirror nip05.rs on purpose:

  • is_valid_identifier(input) -> bool / is_dot_bit(input)
  • NamecoinAddress::parse(input) -> Result<NamecoinAddress, Error>
    • .namecoin_name(), .local_part(), .is_domain(), .electrum_script_hash()
  • Nip05NamecoinProfile::from_json / from_raw_json
  • Nip05NamecoinProfile::from_json_with_lookup / from_raw_json_with_lookup (ifa-0001 import)
  • verify_from_json / verify_from_raw_json
  • verify_from_json_with_lookup / verify_from_raw_json_with_lookup
  • expand_imports + DEFAULT_IMPORT_DEPTH (ifa-0001 import expansion building block)
  • extract_nostr_from_value (exported for non-nostr field inspection)
  • build_name_index_script / electrum_script_hash / parse_name_update_script
  • DEFAULT_ELECTRUMX_SERVERS: &[ElectrumxServer]

Out of scope / follow-ups

  • I deliberately did not modify nip05.rs to auto-delegate. Keeping the two decoupled means callers who don't care about .bit pay nothing, and the three-line chain at the call site is clearer than implicit fall-through.
  • A higher-level async ElectrumX query helper (analogous to nostr-sdk's NIP-05 fetcher) belongs in a sibling crate; I'm happy to follow up with one if maintainers want it bundled here.
  • No README / supported-NIPs-table change — .bit is a transport variant under NIP-05, not a separate NIP. Happy to add a note if you'd prefer.

Companion ports

For reviewers who want the canonical reference: the parser and JSON-extraction rules are identical to those in mstrofnone/nostrlib-nip05-namecoin and the test fixtures in nbd-wtf/nostr-tools#533 line up 1:1 with the ones in this PR.

Happy to split, rename, restructure, or trim — whatever shape lands easiest.

mstrofnone added 2 commits November 3, 2026 09:10
Adds nip05namecoin sibling to nip05 that resolves NIP-05 identifiers
rooted in the Namecoin blockchain instead of DNS. Mirrors the existing
nip05 module's no-transport shape: parse identifiers, derive the
ElectrumX scripthash, decode the NAME_UPDATE script returned by
`blockchain.transaction.get`, and extract a pubkey + relay list from
the resulting name value. The actual ElectrumX call is left to the
caller, matching how nip05.rs leaves DNS / HTTP to the caller.

Identifiers accepted:

  - `[email protected]`
  - `example.bit` (uses the `_` root entry)
  - `d/example` (domain namespace)
  - `id/alice` (identity namespace)
  - A leading `nostr:` NIP-21 prefix is tolerated.

Both name-value shapes from the .bit NIP-05 spec are supported:

  - simple:  "nostr": "<hex pubkey>"
  - extended: "nostr": { "names": {...}, "relays": {...} }

The extended-form local-part priority (exact match → "_" root → first
valid entry when the caller asked for root) matches the Kotlin
reference in Amethyst, the Swift port in Nostur, the Dart port that
merged in dart-nostr#44, the Go reference at
mstrofnone/nostrlib-nip05-namecoin, and the TypeScript port at
nbd-wtf/nostr-tools#533.

The module is no_std + alloc compatible — it only depends on serde_json
and bitcoin_hashes::sha256, both already in the crate.

Live verification: the scripthash this module produces for `d/testls`
is b519574e96740a4b3627674a0708e71a73e654a95117fc828b8e177a0579ab42,
which Namecoin ElectrumX servers (e.g. nmc2.bitcoins.sk:57002)
recognise — `blockchain.scripthash.get_history` against it returns the
`d/testls` transaction history.

Public API (mirrors nip05.rs):

  - is_valid_identifier / is_dot_bit
  - NamecoinAddress::parse / .namecoin_name / .local_part / .is_domain
    / .electrum_script_hash
  - Nip05NamecoinProfile::from_json / from_raw_json
  - verify_from_json / verify_from_raw_json
  - extract_nostr_from_value (exported for callers that want other
    fields from the same name value — bitcoin, lightning, http, etc.)
  - build_name_index_script / electrum_script_hash /
    parse_name_update_script
  - DEFAULT_ELECTRUMX_SERVERS

Tests: 23 new unit tests covering identifier parsing, both JSON value
shapes, malformed-input null paths, the NAME_UPDATE script builder +
parser (including OP_PUSHDATA1 framing), and the round-trip
scripthash derivation. `cargo test -p nostr --lib` and
`cargo test -p nostr --lib --all-features` both pass clean (267 / 356
tests). `cargo clippy -p nostr --lib --all-targets -- -D warnings`
is also clean.

A `nip05namecoin` example is added under `crates/nostr/examples/`
to demonstrate the API.
The 520-byte per-name limit on Namecoin makes apex records crowded.
ifa-0001 §"import" lets a name delegate shared blocks into a sibling
name (typically `dd/<name>`) via an `"import"` key on the JSON value.
Without import-chain handling, NIP-05 lookups against records that
use this pattern silently fail: the resolver sees the apex value,
finds no `nostr` field, and returns `ImpossibleToVerify` — never
consulting the imported sibling that actually carries the
`nostr.names` block. `testls.bit`, the canonical demo target,
delegates its names block to `dd/testls` exactly this way.

Add `expand_imports` plus `Nip05NamecoinProfile::from_json_with_lookup`
/ `from_raw_json_with_lookup` and matching `verify_*_with_lookup`
entry points. The expansion accepts the four `import` value shapes
documented in ifa-0001 (bare string, single-array, pair-array with
selector, canonical array-of-arrays), walks the subdomain selector
against the imported value's `map` tree with exact-label / `*` /
default fallback, merges with importer-wins semantics (including
`null` as a suppression marker), recurses up to the spec-minimum
depth of 4, and breaks cycles with a `(name, selector)` visited set.

I/O is lenient: a `None` / malformed / unreachable import is treated
as the empty object `{}`, so transient ElectrumX hiccups don't kill
an otherwise resolvable record (the importer's own fields still
apply). The non-import code path is unchanged and pays zero extra
cost: records with no `import` key never invoke the lookup closure.

The existing sync API surface stays untouched. Callers that don't
need import resolution keep using `from_json` / `from_raw_json` /
`verify_*`; the new `_with_lookup` variants thread a synchronous
`FnMut(&str) -> Option<String>` between fetch and extract. Keeping
the closure sync mirrors the rest of the module, which owns no
transport surface — the caller drives ElectrumX.

23 new tests cover: shorthand forms, canonical array, importer-wins,
null suppression, depth-4 happy path and depth-1 truncation, lookup
returning None / malformed JSON, malformed `import` value, cycle
protection, multi-label selector descent, wildcard and empty-key
fallback, plus integration cases mirroring the `testls.bit`
deployment (bare resolution, named local-part, importer-wins on
`nostr.names`, failed-import preserves local names, no-import record
issues zero extra lookups, verify across imports).

This branch has not been deployed

No deployments
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.

1 participant