feat: NIP-05/Namecoin (.bit) verification plugin#55
Conversation
Add an opt-in NIP-05/Namecoin (.bit) verification plugin so an existing NIP-05 registrar can also act as a .bit registrar without requiring Namecoin expertise. - src/lib/nostr/NIP05Namecoin.ts: parser + ElectrumX TCP+TLS resolver using Node's stdlib net/tls only (no new npm deps). Ships a pinned default ElectrumX server list and pinned cert store. Parser mirrors the rust-nostr nip05namecoin module byte-for-byte (local-part priority exact -> _ -> first valid; d/ and id/ namespaces; nostr: prefix tolerated). - src/lib/nostr/NIP05Namecoin.test.ts: vitest unit tests for the parser and JSON-value extraction. Live ElectrumX integration tests are guarded behind NOSTRCHECK_NAMECOIN_INTEGRATION=1. - plugins/namecoinNIP05.js: plugin entry following the existing plugin() pattern. Disabled by default. Pass-through for non-.bit identifiers so the existing activeNIP05 DNS chain still runs. - readme.md: short additive paragraph under the existing Plugins section.
|
Hello @mstrofnone, thanks for opening this PR Reviewed the branch end-to-end. The verifier logic is solid, tests pass, and integration into the nostraddress controller is straightforward (just needs the executePlugins call site since none of the existing dispatchers handle module=nostraddress yet). src/controllers/nostraddress.ts First, add the import at the top: import { executePlugins } from "../lib/plugins/core.js";Right after this block: const pubkey = (await dbMultiSelect(["hex"],"registered", name == "_" ? "username = ?" : "username = ? and domain = ? and active = 1", name == "_" ? ["public"] : [name, servername]))[0]?.hex;
if (pubkey == undefined) {
logger.info(`getNostraddress - ${name} is not found on ${servername}`, reqInfo.ip);
const result: ResultMessagev2 = {
status: "error",
message: `${name} is not found on ${servername}`,
};
return res.status(404).send(result);
}Right after this block, add the plugins snippet: // Plugins engine execution
const pluginResult = await executePlugins({ module: "nostraddress", pubkey, username: name, ip: reqInfo.ip }, req.hostname);
if (pluginResult === false) {
logger.info(`getNostraddress - ${name} blocked by plugin chain on ${servername}`, reqInfo.ip);
const result: ResultMessagev2 = {
status: "error",
message: `${name} is not found on ${servername}`,
};
return res.status(404).send(result);
}One thing I'd like to see refactored before merging: the plugin currently imports from ../dist/lib/nostr/NIP05Namecoin.js, which couples it to the compiled server tree and breaks if dist/ is cleaned or the build target changes. Could you move the resolver, parser, and ElectrumX client into sibling files under plugins/ (e.g. namecoinNIP05.lib.js) so the plugin is self-contained? |
Move the resolver, parser, JSON value extractor and ElectrumX TCP+TLS client into a sibling JS module so the plugin no longer reaches into the compiled server tree at dist/lib/nostr/NIP05Namecoin.js. Plugin now works regardless of build state and matches the self-contained shape of every other plugin in plugins/. - plugins/namecoinNIP05.lib.js — runtime library, plain ESM JS. - plugins/namecoinNIP05.js — entry, imports from ./namecoinNIP05.lib.js. - plugins/namecoinNIP05.test.js — vitest suite (26 passing, 1 integration-gated). - src/lib/nostr/NIP05Namecoin.ts + .test.ts removed (now redundant). No behaviour change. Parser semantics, JSON extraction priority, pinned ElectrumX servers and pinned cert bundle are byte-identical to the previous TS version.
|
Thanks for the thorough review @quentintaranpino! Refactor: done in b6941c4You're right — reaching into
No behaviour change — parser semantics, JSON extraction priority, pinned ElectrumX server list and pinned cert bundle are byte-identical to the previous TS version. Call site: one design question before I push itHappy to add the Every plugin currently shipped in
I'd lean toward (A) — your shape is the cleaner long-term boundary, and the (And appreciated the JS-conversion offer — the refactor was straightforward, no need.) |
The 520-byte per-name limit on Namecoin makes apex records (`d/<name>`)
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 — like the canonical `testls.bit` demo target —
silently fail: the resolver sees the apex value, finds no `nostr`
field, returns null, and never consults the imported sibling that
actually carries the `nostr.names` block.
This change adds `expandImports(root, lookup, maxDepth = 4)` to the
plugin library and wires it into `resolveNamecoinNIP05` between
"parsed apex JSON value" and "extract the `nostr` field". Records
without an `import` key are returned unchanged with zero extra
ElectrumX queries — regression-guarded by a dedicated test case.
Behaviour mirrors the reference implementations (Kotlin / Swift / Go
/ Rust) so the four NIP-05/Namecoin client stacks agree on what a
given on-chain record resolves to:
- Four shorthand forms for the `import` value accepted alongside
the canonical array-of-arrays:
"import": "d/foo"
"import": ["d/foo"]
"import": ["d/foo", "selector"]
"import": [["d/foo", "sel"], ...]
- Selector walk on the imported value's `map` tree per ifa-0001
§"map" (exact label → "*" wildcard → empty-key default, DNS
right-to-left).
- Importer-wins shallow merge. `null` in the importer is preserved
as a semantic suppression marker (downstream parsers ignore null
as if absent — same outcome either way).
- Recursion budget defaults to four (the spec minimum). Deeper
chains are silently truncated; the importing record's own fields
still apply.
- Cycles are broken by a visited `(name|selector)` set scoped to a
single top-level expansion.
- Lookup failures (null, throw, malformed JSON) are absorbed and
treated as `{}` so a transient ElectrumX hiccup on a sibling does
not nuke an otherwise resolvable record.
- The `import` key is stripped from the final merged result.
Hermetic vitest suite covering all 20 cases pinned by the reference
spec (16 unit + 4 integration), driven by an in-memory fake fetcher.
The pre-existing 26 tests are unchanged; new total is 47 passing, 1
skipped (the live ElectrumX integration test, still gated on
`NOSTRCHECK_NAMECOIN_INTEGRATION=1`).
No new dependencies. `npm run build` clean. ESLint output on the
touched files is byte-identical to `main` (21 pre-existing `no-undef`
errors on Buffer/setTimeout/process; zero new).
Why
Every nostrcheck-server install is already a NIP-05 registrar. With this
plugin, each operator can also be a Namecoin (
.bit) registrar with noNamecoin expertise required: their users register a
.bitidentifierout-of-band, the server checks the on-chain
nostrfield against thepubkey, and that's it. No new infrastructure, no relay changes, no
existing-flow changes.
What
Opt-in plugin + self-contained backend resolver under
plugins/, fullycomposable with the existing DNS-based NIP-05 flow.
plugins/namecoinNIP05.lib.js— plain-ESM JS library (no dependency onthe compiled
dist/tree; no extra npm dependencies).isNamecoinIdentifier(id)— cheap front-door check for.bit,d/,id/patterns. Tolerates a leadingnostr:NIP-21 prefix.parseIdentifier(id)— strict parser. Local-part priorityexact → _ → first validmirrors the rust-nostrnip05namecoinparser byte-for-byte.resolveNamecoinNIP05(id, options?)— runs the ElectrumX query overTCP+TLS using Node's stdlib
tls. Ships a pinned default ElectrumXserver list and a pinned cert store; operators can override the server
list via
options.servers.extractPubkeyFromNamecoinValue(value, localPart)— JSON-shape extractorhandling both the simple
"nostr": "hex"form and the extended"nostr": { "names": {...}, "relays": {...} }form used by Amethyst andthe draft
.bitNIP-05 spec.expandImports(root, lookup, maxDepth = 4)— ifa-0001 §"import" chainresolver (see "Import-chain support" below).
buildNameIndexScript/electrumScriptHash/parseNameUpdateScripthelpers exposed for callers that want to inspect the on-chain script.
plugins/namecoinNIP05.test.js— vitest unit + hermetic-integration suitecovering the parser, JSON-value extraction, scripthash helpers, pinned
defaults, and the full 20-case
expandImportsspec. Live ElectrumXintegration tests are still guarded behind
NOSTRCHECK_NAMECOIN_INTEGRATION=1.plugins/namecoinNIP05.js— opt-in plugin entry following the establishedplugin()pattern. Reads the pubkey's published kind-0 NIP-05 (sharingthe existing
pubkeyMetadata-<pubkey>redis cache key withactiveNIP05)and, for
.bitidentifiers only, resolves the on-chainnostrfield andcompares pubkeys. Non-Namecoin identifiers pass through (
return true) sothe existing
activeNIP05DNS check still owns the verdict.readme.md— short additive paragraph under the existing Pluginssection explaining the opt-in toggle, the composition with
activeNIP05,and the import-chain behaviour.
Import-chain support (ifa-0001 §"import")
The 520-byte per-name limit on Namecoin makes apex records (
d/<name>)crowded. ifa-0001 §"import" lets a name delegate shared blocks into a
sibling name (typically
dd/<name>) via an"import"key on the JSONvalue. Without import-chain handling, NIP-05 lookups against records that
use this pattern — including the canonical
testls.bitdemo target —silently fail: the resolver sees the apex value, finds no
nostrfield,returns null, and never consults the imported sibling that actually carries
the
nostr.namesblock.expandImportsrecursively merges imported sibling values into theimporting object with importer-wins precedence (the importer's keys, even
nullones, override the imported counterpart per spec), a defaultrecursion depth of four (the spec minimum), DNS-right-to-left
map-treeselector walking with
*/empty-key fallbacks, cycle protection via avisited
(name|selector)set, and lenient absorption of lookup failures.Records without an
importkey pay zero extra ElectrumX queries — pinnedby a dedicated regression test (case 19 in the test suite).
Behaviour is line-for-line aligned with the reference implementations in
Kotlin (Amethyst), Swift (Nostur), Go and Rust so the four NIP-05/Namecoin
client stacks agree on what a given on-chain record resolves to.
Spec reference
.bitNIP-05 spec.nip05namecoinmodule (parser shape used here as the source of truth)..bitresolver proposal.applesauce-loaders/namecoin-identity-loader).Nip05NamecoinResolver).Plugin contract
Follows the established
plugin()pattern (seeplugins/activeNIP05.js,plugins/pubkeyFollower.js):order: 2— runs afteractiveNIP05(order: 1) so the DNS verdict isestablished before this plugin contributes its
.bitverdict.module: ''— matches the convention used by every other shipped plugin.Operators wire the plugin into the desired hookpoint via the existing
plugins config UI / config file.
execute(input, globals)— returnsboolean. Returnstrue(pass-through)for non-Namecoin identifiers so DNS-side plugins handle the verdict;
returns
true/falsebased on on-chain match for.bitidentifiers;returns
falseon resolver / network failure.Trust model
nmc2.bitcoins.sk:57002andelectrumx.testls.space:50002, both withusePinnedTrustStore: true.PINNED_ELECTRUMX_CERTS(copied verbatim from theupstream Kotlin / Go reference implementations).
hostname binding is always enforced. The pinned-cert path is loaded via
Node's
tls.createSecureContext({ ca: ... })with a hostname check incheckServerIdentity.default list by passing
options.serverstoresolveNamecoinNIP05.No changes to existing plugins or core flows
The plugin loader, every existing plugin, the relay, the registrar flow,
and every other module on
mainare untouched. The diff is strictlyadditive (one new plugin entry, one new self-contained library module,
one new test file, one paragraph in the readme).
Testing
Locally on
feat/nip05-namecoin:npm install— clean.npm run build— clean (no new TypeScript errors).npm run linton the touched files — byte-identical tomain(21pre-existing
no-undeferrors onBuffer/setTimeout/process;zero new errors introduced by this PR).
npx vitest run plugins/namecoinNIP05.test.js— 47 passing, 1 skipped(live ElectrumX integration, gated on
NOSTRCHECK_NAMECOIN_INTEGRATION=1).The 21 new tests cover the full 20-case ifa-0001 §"import" spec (16 unit
DEFAULT_IMPORT_MAX_DEPTHto four.src/tests/*.test.tssuites already require a runningserver on
localhost:3000to pass; their pre-existingECONNREFUSEDfailures are unchanged by this PR.