Skip to content

feat(nip05namecoin): resolve .bit identifiers via Namecoin ElectrumX#533

Open
mstrofnone wants to merge 3 commits into
nbd-wtf:masterfrom
mstrofnone:feat/nip05-namecoin
Open

feat(nip05namecoin): resolve .bit identifiers via Namecoin ElectrumX#533
mstrofnone wants to merge 3 commits into
nbd-wtf:masterfrom
mstrofnone:feat/nip05-namecoin

Conversation

@mstrofnone

Copy link
Copy Markdown

Adds a new nostr-tools/nip05namecoin subpackage that resolves NIP-05 identifiers rooted in the Namecoin blockchain, mirroring the existing nostr-tools/nip05 API so callers can chain the two without reshaping their code.

import * as namecoin from 'nostr-tools/nip05namecoin'
import * as nip05 from 'nostr-tools/nip05'

async function resolve(input) {
  return namecoin.isValidIdentifier(input)
    ? await namecoin.queryProfile(input)
    : await nip05.queryProfile(input)
}

await resolve('testls.bit')
// → { pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' }

Identifiers accepted

  • alice@example.bit
  • example.bit (uses the _ root entry)
  • d/example (domain namespace)
  • id/alice (identity namespace)
  • Leading nostr: NIP-21 prefix is tolerated.

How it works

Resolution goes through a public Namecoin ElectrumX server over WSS:

  1. Build the name-index script for d/<domain> or id/<name> and take its electrum scripthash.
  2. blockchain.scripthash.get_history → most recent tx.
  3. blockchain.transaction.get <tx> true → parse the NAME_UPDATE output, extract the JSON value.
  4. blockchain.headers.subscribe → current height, used for the expiry check (NAME_EXPIRE_DEPTH = 36000).
  5. Pull the nostr field out of the value JSON. Both the simple "nostr": "hex" form and the extended "nostr": { names: {...}, relays: {...} } form used by Amethyst / the .bit NIP-05 spec draft are supported.

Ported from the Go reference at mstrofnone/nostrlib-nip05-namecoin, itself a port of the Kotlin implementation in Amethyst and the Swift port in Nostur. The parser shape, local-part priority (exact → _ → first valid), and JSON extraction rules match those implementations byte-for-byte.

Public API

Signatures mirror ./nip05.ts on purpose:

  • isValidIdentifier(input) / isDotBit
  • queryProfile(input, servers?) → ProfilePointer | null
  • isValid(pubkey, input) → boolean
  • useWebSocketImplementation(impl)
  • extractNostrFromValue(valueJSON, parsed) (exported for callers that want to inspect other fields — bitcoin, lightning, http, etc. — from the same name value)
  • DEFAULT_ELECTRUMX_SERVERS: ElectrumXServer[]

Dependencies

Zero new runtime deps. SHA-256 goes through @noble/hashes (already a dependency), and the transport uses the global WebSocket (native in browsers and Node ≥ 22). Users on older Node can plug in ws via useWebSocketImplementation, matching the existing useFetchImplementation pattern in nip05.ts.

Transport / TLS notes

The two long-running public Namecoin ElectrumX operators (electrumx.testls.space, nmc2.bitcoins.sk) currently serve self-signed TLS certificates. Browsers will refuse those out of the box. In Node, callers who want to trust the pinned certs can plug in a ws instance configured with a custom https.Agent:

import WebSocket from 'ws'
import https from 'node:https'
import { useWebSocketImplementation } from 'nostr-tools/nip05namecoin'

const agent = new https.Agent({ ca: [PINNED_PEM] })
class PinnedWebSocket extends WebSocket {
  constructor(url, protocols) { super(url, protocols, { agent }) }
}
useWebSocketImplementation(PinnedWebSocket)

Happy to change this — options I considered:

  • Ship the pinned PEMs inside the module and require a pluggable WebSocket. (Current shape.)
  • Ship a small dep on ws and pin automatically in Node. (One new dep.)
  • Accept cert?: string on each ElectrumXServer and let callers supply PEMs. (More API surface.)

The current shape keeps the module zero-dep and leaves the trust decision to the caller, which felt most in character for this repo. Happy to reshape if you'd prefer a different tradeoff.

Tests

15 new tests under nip05namecoin.test.ts, all passing:

  • Identifier parser, including NIP-21 prefix and casing edge cases.
  • Both JSON value shapes (simple + extended), including the id/ / pubkey / relays path.
  • Malformed-input null paths.
  • NAME_UPDATE script builder + parser.
  • End-to-end queryProfile against a mock-socket ElectrumX server, including server fallback on transport error.

The suite uses mock-socket, already a devDependency. It was also live-verified against testls.bit (root → 460c25e6…) and m@testls.bit (extended names map → 6cdebcca…) on electrumx.testls.space.

bun test v1.3.9 (cf6cdbbb)
nip05namecoin.test.ts:
…
 15 pass
 0 fail
 42 expect() calls

The rest of the test suite is unaffected; the only pre-existing fails I see on master are three pool/reconnect timeouts and one live wss://relay.damus.io test, none related to this change.

Out of scope / follow-ups

  • I deliberately did not modify nip05.ts to auto-delegate. Keeping the two modules decoupled makes it easy to bundle only one, and the three-line chain at the call site is clearer than implicit fall-through.
  • No CLI change here; that belongs in nak.
  • A companion Go port exists at mstrofnone/nostrlib-nip05-namecoin proposed for fiatjaf.com/nostr/nip05/namecoin, and the Swift/Kotlin implementations are already shipping in Nostur PR #60 / Amethyst PR #2198.

Happy to split, rename, fold into nip05.ts, or drop the pinned-cert story entirely — whatever shape lands easiest.

Adds a new `nostr-tools/nip05namecoin` subpackage that mirrors the
`nip05` module's API for Namecoin-rooted NIP-05 identifiers:

  - alice@example.bit
  - example.bit (uses the `_` root entry)
  - d/example   (domain namespace)
  - id/alice    (identity namespace)
  - an optional `nostr:` NIP-21 prefix is tolerated

Resolution walks the Namecoin blockchain via a public ElectrumX server
over WSS: build the name-index scripthash, fetch the latest
NAME_UPDATE transaction for `d/<domain>` or `id/<name>`, parse its
value JSON, and extract the `nostr` field (both the simple
`"nostr": "hex"` form and the extended
`"nostr": { names, relays }` form used by Amethyst and the .bit
NIP-05 spec draft).

Public API (signatures mirror `./nip05.ts`):

  - isValidIdentifier(input) / isDotBit
  - queryProfile(input, servers?) -> ProfilePointer | null
  - isValid(pubkey, input) -> boolean
  - useWebSocketImplementation(impl)
  - extractNostrFromValue(json, parsed)
  - DEFAULT_ELECTRUMX_SERVERS
  - ElectrumXServer type

Zero new runtime dependencies: SHA-256 via @noble/hashes (already a
dep), WSS via the global `WebSocket` (native in browsers and
Node >= 22), or a user-supplied implementation.

Ported from the Go reference at
https://github.com/mstrofnone/nostrlib-nip05-namecoin, itself a port
of the Kotlin implementation in Amethyst and the Swift port in
Nostur. Live-verified against testls.bit and m@testls.bit on
electrumx.testls.space.

Tests cover the identifier parser, both JSON value shapes, the
NAME_UPDATE script parser, server fallback, and an end-to-end path
through a mock-socket ElectrumX server.
Adds a second scoped export `nostr-tools/nip05namecoin-node` that
wraps the isomorphic core module with a Node-only transport:

  - Ships the two public Namecoin ElectrumX operators' PEM certs
    inline (expires 2027-05-04 and 2030-10-22).
  - Trusts those certs by SHA-256 fingerprint, matching the Kotlin
    Amethyst and Swift Nostur implementations.
  - Refuses connection attempts to hostnames outside its pinned set.
  - Re-exports `queryProfile`, `isValid`, and the identifier
    predicates so callers that use this module never need to touch
    `useWebSocketImplementation` themselves.

Typical use:

    import { queryProfile, install } from 'nostr-tools/nip05namecoin-node'
    await install()
    await queryProfile('testls.bit')
    // -> { pubkey: '460c25e6…' }

`ws` is an *optional* peer dependency: only users of this module
need it installed. The core `nostr-tools/nip05namecoin` module
remains zero-dep and browser-compatible.

install() is async (uses dynamic `import('ws')`).
installPinnedWebSocket() is the CJS sibling for consumers in a
require-capable runtime; in pure ESM it throws a clear error
redirecting callers at install().

Live-verified: testls.bit -> 460c25e6… and m@testls.bit -> 6cdebcca…
against electrumx.testls.space:50004 with pinned-fingerprint TLS
validation, no NODE_TLS_REJECT_UNAUTHORIZED needed.

9 new tests (24 namecoin tests total). No changes to the core
isomorphic module.
@mstrofnone

Copy link
Copy Markdown
Author

Added a second scoped export, nostr-tools/nip05namecoin-node (new commit, same PR), that gives Node users a batteries-included transport without any manual ws / tls wiring.

import { queryProfile, install } from 'nostr-tools/nip05namecoin-node'

await install()
await queryProfile('testls.bit')
// → { pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' }

Trust policy:

  • Ships the two public Namecoin ElectrumX operators' PEM certs inline (electrumx.testls.space, expires 2027-05-04; nmc2.bitcoins.sk/46.229.238.187, expires 2030-10-22).
  • rejectUnauthorized: true against the pinned ca: bundle, and checkServerIdentity overridden to assert the peer's SHA-256 fingerprint is in PINNED_SHA256_FINGERPRINTS. This matches the Kotlin Amethyst and Swift Nostur implementations.
  • Refuses connection attempts to hostnames outside ALLOWED_HOSTNAMES (extendable for private ElectrumX servers).

ws is an optional peer dependency — only users of this module need it installed. The core nostr-tools/nip05namecoin module stays zero-dep and isomorphic.

APIs:

  • install(): Promise<void> — async, dynamic-imports ws, works in pure ESM and CJS. Preferred.
  • installPinnedWebSocket(): void — sync sibling for CJS consumers. Throws in pure-ESM runtimes with a clear redirect to install().
  • queryProfile, isValid, isValidIdentifier, isDotBit, extractNostrFromValue, DEFAULT_ELECTRUMX_SERVERS, ElectrumXServer — re-exported from core so -node is a drop-in replacement for routing.
  • PINNED_SHA256_FINGERPRINTS, PINNED_CERTIFICATES_PEM, ALLOWED_HOSTNAMES, verifyFingerprint — exposed for inspection/rotation.

Live-verified end-to-end against the real Namecoin blockchain:

testls.bit    → { pubkey: '460c25e682…' }  (2.3s)
m@testls.bit  → { pubkey: '6cdebccabd…' }  (2.1s)  (extended names map)

9 new tests covering the pinned-fingerprint verifier, frozen PEM/fingerprint bundles, install path, and non-.bit short-circuit. 24 namecoin tests total, all green. No impact on the 339 other passing tests.

Updated README explains both the isomorphic module and the Node convenience path. Still happy to reshape — for example, fold -node into nip05namecoin.ts behind a lazy init, or drop the installPinnedWebSocket sync sibling entirely — whatever shape lands easiest.

@mstrofnone

Copy link
Copy Markdown
Author

Hi @fiatjaf! Does this hit? Any feedback?

An explorer is here:
http://6cbn4rskfdr647otej7gpqlmpqcmj723vg2eoeuu7ljbwu6cpdebozyd.onion:8080/

Without this, any `.bit` record whose value JSON uses ifa-0001's
`"import"` shorthand resolves to `null` even when a valid `nostr`
pubkey is one hop away. `"import"` is widely used in real Namecoin
records (the canonical way for many small zones to alias a shared
DNS / Nostr block from a parent name).

This change ports the import resolver already shipping in:

  - Amethyst (Kotlin):
    quartz/.../NamecoinImportResolver.kt
  - Nostur (Swift): nostur/.../NamecoinImportResolver.swift
  - dart-nostr (Dart): lib/src/namecoin/{value,record_parser}.dart
  - nostrlib-nip05-namecoin (Go reference impl)

All four resolve the same set of import shapes and merge rules, so
behaviour matches byte-for-byte.

New private module `nip05namecoin-import.ts` (transport-free,
fetcher-injected, isomorphic) wired into `queryProfile` between the
`name_show` call and the `nostr`-field extractor. The fetcher reuses
the same server pool and absorbs errors so a transient ElectrumX hiccup
on an imported name doesn't kill the outer lookup.

Behavioural contract (matches ifa-0001):

  - Accepted import shapes: bare string "d/foo", single-array
    ["d/foo"], pair-array ["d/foo","sub"], canonical
    [["d/foo"], ["d/bar","sub"]].
  - Selector walks the imported value's `map` tree: exact-label
    match → `*` wildcard → `""` default → null.
  - Recursion depth: 4 (the spec-mandated minimum), configurable.
  - Cycles broken by a visited-set keyed on name|selector.
  - Importer-wins merge: importer keys (including JSON `null`)
    suppress imported counterparts.
  - Failed import lookups contribute nothing (best-effort), so a
    flaky imported name doesn't fail the whole resolution.
  - Records without an `"import"` key short-circuit — no extra I/O.

`extractNostrFromValue` keeps its public `(valueJSON: string, parsed)`
signature; an internal `extractNostrFromObject` takes the pre-parsed
merged record so we don't re-stringify after expansion.

Tests:
  - 22 hermetic unit tests in `nip05namecoin-import.test.ts`
    covering every accepted shape, importer-wins, null suppression,
    recursion depth, depth truncation, cycle break, malformed JSON /
    missing imports / non-object import values, selector walk order,
    wildcard, default, exact-match precedence, non-object terminator.
  - 3 end-to-end tests in `nip05namecoin.test.ts` exercising
    `queryProfile` through a multi-name mock ElectrumX server
    (`startMultiNameFakeElectrumX`): import chain resolves when
    importer has no `nostr` field; importer's `nostr` wins over
    imported one; records without `import` still resolve unchanged.

49 namecoin tests total now (was 24), all green.
@mstrofnone

Copy link
Copy Markdown
Author

Pushed e58a0df: ifa-0001 import chain support, so records using "import" shorthand resolve correctly. Without it any .bit record that aliases its nostr block from a parent name returned null.

New module nip05namecoin-import.ts (transport-free, fetcher-injected), wired into queryProfile between name_show and the nostr-field extractor. Behaviour matches the same resolver shipping in Amethyst (Kotlin), Nostur (Swift), and dart-nostr (Dart) — same accepted import shapes (bare string, pair-array, canonical array-of-arrays), same selector-walk semantics (exact → *"" → null), same recursion-depth-4 default, same cycle break, same importer-wins merge with null suppression.

Records without an "import" key short-circuit — no extra I/O — so the common case stays one round-trip.

Tests: 22 new unit tests + 3 e2e tests through a multi-name mock ElectrumX server. 49 namecoin tests total now (was 24), all green. bun test nip05namecoin to run them.

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