Skip to content

feat: NIP-05/Namecoin (.bit) verification plugin#55

Open
mstrofnone wants to merge 3 commits into
quentintaranpino:mainfrom
mstrofnone:feat/nip05-namecoin
Open

feat: NIP-05/Namecoin (.bit) verification plugin#55
mstrofnone wants to merge 3 commits into
quentintaranpino:mainfrom
mstrofnone:feat/nip05-namecoin

Conversation

@mstrofnone

@mstrofnone mstrofnone commented May 21, 2026

Copy link
Copy Markdown

Why

Every nostrcheck-server install is already a NIP-05 registrar. With this
plugin, each operator can also be a Namecoin (.bit) registrar with no
Namecoin expertise required: their users register a .bit identifier
out-of-band, the server checks the on-chain nostr field against the
pubkey, and that's it. No new infrastructure, no relay changes, no
existing-flow changes.

What

Opt-in plugin + self-contained backend resolver under plugins/, fully
composable with the existing DNS-based NIP-05 flow.

  • plugins/namecoinNIP05.lib.js — plain-ESM JS library (no dependency on
    the compiled dist/ tree; no extra npm dependencies).

    • isNamecoinIdentifier(id) — cheap front-door check for .bit, d/,
      id/ patterns. Tolerates a leading nostr: NIP-21 prefix.
    • parseIdentifier(id) — strict parser. Local-part priority exact → _ → first valid mirrors the rust-nostr nip05namecoin parser byte-for-byte.
    • resolveNamecoinNIP05(id, options?) — runs the ElectrumX query over
      TCP+TLS using Node's stdlib tls. Ships a pinned default ElectrumX
      server list and a pinned cert store; operators can override the server
      list via options.servers.
    • extractPubkeyFromNamecoinValue(value, localPart) — JSON-shape extractor
      handling both the simple "nostr": "hex" form and the extended
      "nostr": { "names": {...}, "relays": {...} } form used by Amethyst and
      the draft .bit NIP-05 spec.
    • expandImports(root, lookup, maxDepth = 4) — ifa-0001 §"import" chain
      resolver (see "Import-chain support" below).
    • buildNameIndexScript / electrumScriptHash / parseNameUpdateScript
      helpers exposed for callers that want to inspect the on-chain script.
  • plugins/namecoinNIP05.test.js — vitest unit + hermetic-integration suite
    covering the parser, JSON-value extraction, scripthash helpers, pinned
    defaults, and the full 20-case expandImports spec. Live ElectrumX
    integration tests are still guarded behind
    NOSTRCHECK_NAMECOIN_INTEGRATION=1.

  • plugins/namecoinNIP05.js — opt-in plugin entry following the established
    plugin() pattern. Reads the pubkey's published kind-0 NIP-05 (sharing
    the existing pubkeyMetadata-<pubkey> redis cache key with activeNIP05)
    and, for .bit identifiers only, resolves the on-chain nostr field and
    compares pubkeys. Non-Namecoin identifiers pass through (return true) so
    the existing activeNIP05 DNS check still owns the verdict.

  • readme.md — short additive paragraph under the existing Plugins
    section 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 JSON
value. Without import-chain handling, NIP-05 lookups against records that
use this pattern — including 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.

expandImports recursively merges imported sibling values into the
importing object with importer-wins precedence (the importer's keys, even
null ones, override the imported counterpart per spec), a default
recursion depth of four (the spec minimum), DNS-right-to-left map-tree
selector walking with */empty-key fallbacks, cycle protection via a
visited (name|selector) set, and lenient absorption of lookup failures.
Records without an import key pay zero extra ElectrumX queries — pinned
by 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

Plugin contract

Follows the established plugin() pattern (see plugins/activeNIP05.js,
plugins/pubkeyFollower.js):

  • order: 2 — runs after activeNIP05 (order: 1) so the DNS verdict is
    established before this plugin contributes its .bit verdict.
  • 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) — returns boolean. Returns true (pass-through)
    for non-Namecoin identifiers so DNS-side plugins handle the verdict;
    returns true / false based on on-chain match for .bit identifiers;
    returns false on resolver / network failure.

Trust model

  • Default ElectrumX server list is pinned in source:
    nmc2.bitcoins.sk:57002 and electrumx.testls.space:50002, both with
    usePinnedTrustStore: true.
  • PEM bundle pinned in PINNED_ELECTRUMX_CERTS (copied verbatim from the
    upstream Kotlin / Go reference implementations).
  • TLS validation: the system trust store or the pinned bundle counts;
    hostname binding is always enforced. The pinned-cert path is loaded via
    Node's tls.createSecureContext({ ca: ... }) with a hostname check in
    checkServerIdentity.
  • Operators that prefer to run their own ElectrumX server can override the
    default list by passing options.servers to resolveNamecoinNIP05.

No changes to existing plugins or core flows

The plugin loader, every existing plugin, the relay, the registrar flow,
and every other module on main are untouched. The diff is strictly
additive (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 lint on the touched files — byte-identical to main (21
    pre-existing no-undef errors on Buffer / 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
    • 4 integration) plus a guard pinning DEFAULT_IMPORT_MAX_DEPTH to four.
  • The existing src/tests/*.test.ts suites already require a running
    server on localhost:3000 to pass; their pre-existing
    ECONNREFUSED failures are unchanged by this PR.

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.
@quentintaranpino quentintaranpino self-requested a review May 21, 2026 10:39
@quentintaranpino quentintaranpino self-assigned this May 21, 2026
@quentintaranpino quentintaranpino added enhancement New feature or request plugin Plugin labels May 21, 2026
@quentintaranpino

Copy link
Copy Markdown
Owner

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?
That way there are no core changes required. Happy to help with the JS conversion if useful

@quentintaranpino quentintaranpino added this to the 0.8.0 milestone May 21, 2026
@quentintaranpino quentintaranpino marked this pull request as ready for review May 21, 2026 11:34
chatgpt-codex-connector[bot]

This comment was marked as low quality.

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.
@mstrofnone

Copy link
Copy Markdown
Author

Thanks for the thorough review @quentintaranpino!

Refactor: done in b6941c4

You're right — reaching into dist/lib/nostr/NIP05Namecoin.js was a footgun. The plugin is now self-contained under plugins/:

  • plugins/namecoinNIP05.lib.js — resolver, parser, JSON extractor, ElectrumX TCP+TLS client. Plain ESM JS, no extra npm deps (Node's stdlib tls + crypto).
  • plugins/namecoinNIP05.js — entry, imports from ./namecoinNIP05.lib.js.
  • plugins/namecoinNIP05.test.js — the existing vitest suite ported over (26 passing, 1 integration-gated behind NOSTRCHECK_NAMECOIN_INTEGRATION=1).
  • src/lib/nostr/NIP05Namecoin.{ts,test.ts} removed (now redundant).

No behaviour change — parser semantics, JSON extraction priority, pinned ElectrumX server list and pinned cert bundle are byte-identical to the previous TS version. npm run build is clean and npm run lint reports the same 123 pre-existing errors as main (no new lint hits from this PR).

Call site: one design question before I push it

Happy to add the executePlugins({ module: "nostraddress", ... }, req.hostname) snippet in src/controllers/nostraddress.ts exactly as you wrote it — but I want to flag one small convention divergence so you can pick the shape you prefer:

Every plugin currently shipped in plugins/ uses module: '' (activeNIP05, pubkeyFollower, pubkeyFollowing, pubkeyWoTFollowing, ipCountry, examplePlugin), and namecoinNIP05 follows that convention. Your snippet, on the other hand, dispatches with module: "nostraddress" — which would make this plugin the first shipped plugin to use a non-empty module discriminator. Two ways to land it:

  • (A) Match your snippet as written. Change namecoinNIP05 to module: "nostraddress". Simplest path, gets the plugin reachable from the NIP-05 endpoint immediately. Sets the precedent that hookpoint-specific plugins use a hookpoint-matching module string.
  • (B) Keep module: '' (convention parity). Add the executePlugins call site you described, but with module: "" (or no module filter at the call site). The NIP-05 hookpoint then runs every module: '' plugin — which is fine for our case (only namecoinNIP05 would actually do work for .bit identifiers; everything else short-circuits) but it does mean other module: '' plugins would now also fire on the NIP-05 endpoint.

I'd lean toward (A) — your shape is the cleaner long-term boundary, and the module: '' convention seems more like inertia than intent. But it's your repo and your call. Let me know which you'd prefer and I'll push the controller change in a follow-up commit on this branch. Could even ship it in this PR if you'd like, or split it into a separate PR alongside switching module to "nostraddress" — whatever's easiest to review.

(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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request plugin Plugin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants