feat(nip05namecoin): resolve .bit identifiers via Namecoin ElectrumX#533
feat(nip05namecoin): resolve .bit identifiers via Namecoin ElectrumX#533mstrofnone wants to merge 3 commits into
Conversation
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.
|
Added a second scoped export, import { queryProfile, install } from 'nostr-tools/nip05namecoin-node'
await install()
await queryProfile('testls.bit')
// → { pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' }Trust policy:
APIs:
Live-verified end-to-end against the real Namecoin blockchain: 9 new tests covering the pinned-fingerprint verifier, frozen PEM/fingerprint bundles, install path, and non- Updated README explains both the isomorphic module and the Node convenience path. Still happy to reshape — for example, fold |
|
Hi @fiatjaf! Does this hit? Any feedback? An explorer is here: |
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.
|
Pushed e58a0df: ifa-0001 New module Records without an Tests: 22 new unit tests + 3 e2e tests through a multi-name mock ElectrumX server. 49 namecoin tests total now (was 24), all green. |
Adds a new
nostr-tools/nip05namecoinsubpackage that resolves NIP-05 identifiers rooted in the Namecoin blockchain, mirroring the existingnostr-tools/nip05API so callers can chain the two without reshaping their code.Identifiers accepted
alice@example.bitexample.bit(uses the_root entry)d/example(domain namespace)id/alice(identity namespace)nostr:NIP-21 prefix is tolerated.How it works
Resolution goes through a public Namecoin ElectrumX server over WSS:
d/<domain>orid/<name>and take its electrum scripthash.blockchain.scripthash.get_history→ most recent tx.blockchain.transaction.get <tx> true→ parse the NAME_UPDATE output, extract the JSON value.blockchain.headers.subscribe→ current height, used for the expiry check (NAME_EXPIRE_DEPTH = 36000).nostrfield out of the value JSON. Both the simple"nostr": "hex"form and the extended"nostr": { names: {...}, relays: {...} }form used by Amethyst / the.bitNIP-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.tson purpose:isValidIdentifier(input) / isDotBitqueryProfile(input, servers?) → ProfilePointer | nullisValid(pubkey, input) → booleanuseWebSocketImplementation(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 globalWebSocket(native in browsers and Node ≥ 22). Users on older Node can plug inwsviauseWebSocketImplementation, matching the existinguseFetchImplementationpattern innip05.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 awsinstance configured with a customhttps.Agent:Happy to change this — options I considered:
wsand pin automatically in Node. (One new dep.)cert?: stringon eachElectrumXServerand 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:id//pubkey/relayspath.queryProfileagainst a mock-socket ElectrumX server, including server fallback on transport error.The suite uses
mock-socket, already a devDependency. It was also live-verified againsttestls.bit(root →460c25e6…) andm@testls.bit(extended names map →6cdebcca…) onelectrumx.testls.space.The rest of the test suite is unaffected; the only pre-existing fails I see on
masterare three pool/reconnect timeouts and one livewss://relay.damus.iotest, none related to this change.Out of scope / follow-ups
nip05.tsto 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.nak.mstrofnone/nostrlib-nip05-namecoinproposed forfiatjaf.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.