Resolver 3438d558: ASCII-clean install.ps1 + multi-TLD NRPT + VPS-first electrum

Parallel session was right — the previously-shipped f94834b5 pre-dated:
  1. the ASCII em-dash sweep in setup/install.ps1 (Inno's [Run] uses
     powershell.exe / PS 5.1; em-dashes in the file crashed the parser
     under UAC, so the installer copied files but never registered the
     scheduled task or NRPT rule)
  2. the multi-TLD NRPT expansion (added .p2p .bit .nav alongside .bch)
  3. the VPS-primary electrum server list — resolver-web.js now dials
     wss://coinspectrum.duckdns.org:50011 first (Silent Mode's own
     BCHN-backed BNS-only indexer), public servers as fallback

Result: a real user installing 3438d558 on a fresh Windows box now gets
a working system-wide resolver with no manual steps.

Deployed to dl.silentmode.st and mirrored to Sia bns/silentmode/.
This commit is contained in:
Local Dev 2026-07-31 23:09:23 +02:00
parent 6ff181b6c2
commit 0f144d94b9
3 changed files with 338 additions and 0 deletions

191
DESIGN-collision-modes.md Normal file
View file

@ -0,0 +1,191 @@
# Design: BCNR ↔ ICANN collision modes
**Status:** design (2026-07-30). Daemon-side hard modes ✅ built; client-side
soft mode + switcher 📐 to build.
The definitive spec for how a client picks between the **Bitcoin Cash Name
Registry (BCNR)** and **ICANN** when both roots claim the same name — a *user
choice*, modeled on the OS "Open with…" pattern.
## 1. The problem in one paragraph
Two roots resolve names now: ICANN (traditional DNS) and BCNR (on-chain). Most
names live in only one root (`google.com` on ICANN, `hello.bch` on BCNR — no
conflict). But sometimes both claim the same name: you register `syskypo.de` on
BCNR while `syskypo.de` also exists at the German .de registry. That's a
**collision**. Somebody has to decide which one loads. This document defines who
decides, how, and where the code lives.
## 2. Terminology
- **BCNR** — Bitcoin Cash Name Registry (the on-chain registry, this project).
- **ICANN** — the incumbent DNS root and its registries.
- **BCNR-native TLD** — a TLD that BCNR treats as its own (currently `.bch`,
and any TLD the on-chain **root TLD certificate** lists). Not in the IANA root.
- **ICANN TLD** — a TLD in the IANA root (`.com`, `.de`, `.dev`, …).
- **Collision** — a name that resolves in **both** roots (only possible on an
ICANN TLD, since ICANN doesn't serve BCNR-native TLDs).
- **Hard mode** — the daemon picks automatically per policy, no prompting.
- **Soft mode** — the client prompts on collision, remembers the choice.
- **Switcher** — a per-tab UI to flip an already-loaded page between roots.
## 3. The three modes
Modes 1 and 2 are **hard**: implemented in the daemon; they always resolve
automatically. Mode 3 is **soft**: implemented in the client; it prompts on
collision (like "Open with…") and delegates non-collision cases to the daemon
underneath.
| # | Policy | Where | Collision winner | Non-collision |
|---|---|---|---|---|
| **1** | **BCNR-priority** *(default)* | daemon | BCNR | falls through to the other root |
| **2** | **ICANN-priority** | daemon | ICANN | falls through to the other root |
| **3** | **Soft — Ask each time** | client (Theseus / extension) | asked, remembered per name/TLD | same as underlying hard mode |
Env: `BNS_POLICY=bcnr-first` (default) · `BNS_POLICY=icann-first`.
(Alias: `bns-first` → `bcnr-first`.)
**A previous "strict / native" hard mode was removed** — its user-visible outputs
matched mode 2 for every real case. The only genuinely new user-facing behavior
soft brings is *prompting*, so it lives in one place: the client.
## 4. The mental model — "Open with…"
Users already know this pattern from every OS:
| OS "Open with…" | BCNR/ICANN soft mode |
|---|---|
| One app can open the file → opens automatically | No collision → resolves automatically |
| Multiple apps → dialog appears | Collision → prompt appears |
| "Always use this app" checkbox → sets a default | "Always use for this name" → remembers per name |
| Change default in Settings → new default sticks | Change in browser Settings → new default sticks |
| Right-click → "Open with…" (one-off override) | Live switcher (⛓ BCNR ↔ 🌐 ICANN) → per-tab flip |
Zero learning curve — build the same UX and users get it immediately.
## 5. Granularity — same as file associations
OSes let you set defaults per file *type* AND per file. The client does both,
resolved top-to-bottom on collision:
1. **Per-name override** — "always use ICANN for `syskypo.de`" — most specific wins.
2. **Per-TLD default** — "always use ICANN for `.de`" — set once, applies to all `.de`.
3. **Global tie-breaker** — the hard-mode setting of the underlying daemon
(bcnr-first or icann-first). Whatever the user picked at install.
If none of #1/#2 have an entry and mode is **soft**, prompt. Otherwise auto-resolve.
## 6. The live switcher
Always available in the browser chrome (Theseus natively; other browsers via the
extension). It:
- Shows a badge — **⛓ BCNR** or **🌐 ICANN** — indicating what's serving the tab.
- One click flips it. Reload with the *other* root. Reversible.
- **Offers to remember** — "Always use ICANN for this name? / for all .de?" —
which writes the per-name or per-TLD default (§5), so the choice persists.
- Available even in hard modes — a hard-mode user can still one-off override
for a specific tab without changing their global setting.
## 7. The prompt (soft mode)
Fires only when: mode is soft, both roots have an answer, and no per-name /
per-TLD override exists. Contents:
```
syskypo.de exists in two registries.
⛓ Bitcoin Cash Name Registry (BCNR) preview: <domain-owner label>
🌐 ICANN (.de national registry) preview: real IP / cert issuer
[ ] Always use this choice for syskypo.de
[ ] Always use this choice for all .de names
[ Open with BCNR ] [ Open with ICANN ] [ Cancel ]
```
Design notes: **don't preload either page** before the choice (privacy — no
silent request leaks); the previews come from **cached record metadata**, not
live fetches. `[Cancel]` returns to the previous page.
## 8. Storage — user choices
Small JSON in the client's userData (Theseus) or extension storage:
```json
{
"collisions": {
"byName": {
"syskypo.de": "icann",
"myname.dev": "bcnr"
},
"byTld": {
"de": "icann"
},
"history": [
{ "name": "syskypo.de", "at": "2026-07-30T12:34:56Z", "chose": "icann" }
]
}
}
```
Reset: **"Reset default registries"** (like OS "Reset default apps") wipes
`byName` and `byTld` in one action.
## 9. Where the two lists come from (both must stay fresh)
Collision detection needs to classify every TLD as ICANN, BCNR-native, or both:
- **ICANN root** → **IANA** (`iana-tlds.txt`, currently `D:\Dev\NameCoin\src\data\`).
A tiny file (~1400 lines), updated by IANA rarely. Refresh script runs monthly
or ships with the installer/extension update.
- **BCNR-native TLDs** → the **on-chain root TLD certificate**:
**`tlds.bch`** (✅ live on chipnet). The `tlds` record is a space-separated list
of activated TLDs; every resolver reads it via `fetchBcnrTlds()`. Adding a TLD
is one on-chain UPDATE. Full spec: [`DESIGN-root-tld-cert.md`](DESIGN-root-tld-cert.md).
Cached with a short TTL by clients.
The two sets together define what "collision" means and let clients preempt the
soft prompt (or skip it when only one root claims the TLD).
## 10. Where each piece lives
| Piece | Home | Status |
|---|---|---|
| Hard modes 1 & 2 | `bnsd` daemon (`BNS_POLICY` env / `--policy` flag) | ✅ built |
| IANA root list | `D:\Dev\NameCoin\src\data\iana-tlds.txt` (+ refresh cron 📐) | ✅ file · 📐 refresher |
| BCNR-native TLD list (on-chain) | root TLD certificate | 📐 |
| Install-time mode prompt | `setup/install.ps1` — routing (surgical/full) + policy | 📐 |
| Soft mode + prompt UI | **Theseus** in-process; **extension** for other browsers | 📐 |
| Live switcher | Theseus chrome; extension toolbar | 📐 |
| Per-name / per-TLD overrides | client userData / extension storage | 📐 |
| Reset defaults | client Settings | 📐 |
| Bootstrap TLD list (before first resolve) | shipped list; refresh from on-chain root cert after startup | 📐 |
## 11. Open questions
- **The prompt's preview** — how much do we show without a network fetch? Cached
record type + a small "site title" hint from the on-chain record set for BCNR;
cached favicon + issuer for ICANN. If nothing cached, prompt shows only the
choice, no preview. **Never fetch before the user chooses.**
- **Uncooperative surfaces** — other browsers running raw against the system
daemon (no extension) get only the hard mode; that's fine and documented. Push
soft users toward Theseus or the extension.
- **Confidence in the BCNR-native list.** If the root TLD certificate is briefly
unreachable, cached list is used with a warning; if never seen, fall back to a
shipped baseline (`bch` + a small set). Never guess.
- **Per-subdomain overrides?** Probably not — per-name and per-TLD cover it.
Adding per-subdomain complicates the storage without a clear use case.
- **Policy roaming across devices?** Explicitly out of scope for v0 — local only.
## 12. Not in scope
- Reputation systems for either root (which name is "trustworthy"). The user
chooses; the client doesn't editorialize.
- Splitting resources across roots (fetching CSS from ICANN, HTML from BCNR).
Each page loads from one root at a time.
- Any automatic "smart" mode that guesses — every collision is either a per-name
override, per-TLD default, or a prompt. Never silent.
---
*This document is the reference for future sessions building the installer, the
Theseus switcher, and the extension. Any deviation should update this file.*

105
DESIGN-root-tld-cert.md Normal file
View file

@ -0,0 +1,105 @@
# Root TLD certificate (BCNR's IANA-root)
**Status: ✅ deployed on chipnet (2026-07-30).**
The authoritative list of BCNR-native TLDs — the "IANA root" for BCNR — lives
in a single on-chain certificate. Clients read it to know which TLDs BCNR
considers its own, which is what makes multi-TLD support and collision
detection work without hardcoded lists.
## The name
**`tlds.bch`** — registered on chipnet (see the wallet's index). One record:
- **`tlds`** (string) — space-separated list of TLD suffixes, lowercase,
`[a-z0-9-]{1,16}` each. Bootstrap set (2026-07-30):
`"bch p2p deviant nav sia dgb nt sm lm"`.
Chosen shape:
- **Space-separated string, not JSON.** Fits the 200-byte OP_RETURN payload
comfortably, mirrors the existing `el` electrum-server-list record
(`electrum.silentmode.bch`), avoids escaping in on-chain payloads.
- **One well-known name at the well-known TLD (`.bch`).** No chicken-and-egg —
a client that only knows `.bch` can still resolve `tlds.bch` and learn the
rest. This is the sole hardcoded name; the rest are on-chain.
## Client API (`resolver-web.js`)
```js
import { ROOT_TLD_NAME, BOOTSTRAP_TLDS,
fetchBcnrTlds, getBcnrTlds, parseTldsRecord,
isBcnrNativeTld } from "./resolver-web.js";
// One-shot: connect, read the on-chain list, return it
const tlds = await fetchBcnrTlds({ WebSocket, electrum: [...] });
// Or reuse an already-built index (no extra round-trip)
const tlds = getBcnrTlds(index);
// Classify a TLD
isBcnrNativeTld(tlds, "p2p") // -> true
isBcnrNativeTld(tlds, "de") // -> false
```
Fallbacks: unpublished / unreachable / empty → `BOOTSTRAP_TLDS = ["bch"]`.
Callers should treat the list as **live but cacheable** (short TTL, e.g. one
hour) — every consumer already has a name-index cache, so add the TLD read to
the same warm path.
## How to update the list
Adding a TLD is one on-chain UPDATE (proves ownership by moving the certificate):
```js
import { loadWallet } from "./src/lib/wallet.js";
import { updateName } from "./src/lib/bns.js";
const w = await loadWallet();
await updateName(w, "tlds.bch", {
tlds: "bch p2p deviant nav sia dgb nt sm lm bit test x silentmode",
});
```
Once the tx confirms and resolver indexes refresh (typically < 60 s), every
client picks up the new TLD without a software update or reinstall. The
certificate is held by the operator wallet on chipnet — for a real deployment
this needs to be a multisig, a covenant, or a governance body (Athenae council,
see `ROADMAP-IDEAS.md §4`) so the root isn't controlled by a single key.
## What this unlocks
1. **Multi-TLD resolution** (already live) — `bnsd` and Theseus accept any
`<label>.<tld>`. The root cert is the "which TLDs count as ours" answer.
2. **Collision detection** for the soft "Open with…" prompt — a client can
preempt the prompt on `.com` (not BCNR-native, only ICANN could have it)
without hardcoding an ICANN-TLD allowlist.
3. **Surgical-mode routing** (`install.ps1 -Mode nrpt`) — the installer can
read the root cert and write one NRPT rule per activated TLD; adding a TLD
on-chain later triggers a re-sync, not a reinstall. (📐 to build.)
4. **Registrar hygiene** — a permissionless registrar (see `ROADMAP-IDEAS.md §3`)
must sell names only under activated TLDs; the root cert is the check.
## Not in scope here
- **Per-TLD registry designation** (which chain / operator backs each TLD, à la
the `.bit` on BCH vs Namecoin question). The current record is flat; if we
need per-TLD metadata, upgrade the record to `tlds` = space-separated tokens
of shape `<tld>|<registry>|…`, mirroring `el`'s `url|ip` shape. Or move to a
Sia-hosted manifest referenced by hash in the record (unlocks arbitrary
metadata without OP_RETURN size worries).
- **Root cert key ownership.** For real use the key holding `tlds.bch` must be
more than one operator's hot wallet — see the governance discussion in
`ROADMAP-IDEAS.md §4`.
## Test / verify
Any BNS resolver (local Fulcrum, VPS BNS indexer, public electrum) reads it:
```js
import WebSocket from "ws";
import { fetchBcnrTlds } from "./src/lib/resolver-web.js";
console.log(await fetchBcnrTlds({
WebSocket, electrum: [{ url: "ws://127.0.0.1:50003" }],
}));
// -> ["bch","p2p","deviant","nav","sia","dgb","nt","sm","lm"]
```
Registration tx (2026-07-30): `f5a34ec7b995541f2f9b10f85d30b29cc76ebf75960e794a9001a68b16f6b3e9`.

View file

@ -35,6 +35,10 @@ function canonicalKey(raw) {
// best-effort fallback: when DNS works the fresh hostname is always used, and the
// authoritative, updatable server list lives on-chain (see ELECTRUM_LIST_NAME).
export const CHIPNET_ELECTRUM = [
// Silent Mode's own BCHN-backed BNS-only indexer (Argus/src/indexer/bns-indexer.js).
// Tried first so we prefer our own infrastructure; the public servers below
// remain as fallbacks and cross-checks against a single-server view.
{ url: "wss://coinspectrum.duckdns.org:50011", ip: "195.184.247.106" },
{ url: "wss://chipnet.bch.ninja:50004", ip: "135.148.236.244" },
{ url: "wss://chipnet.imaginary.cash:50004", ip: "66.29.152.199" },
{ url: "wss://tbch4.loping.net:62002", ip: "183.89.202.207" },
@ -323,3 +327,41 @@ export function getElectrumServers(index) {
export async function fetchElectrumServers(opts = {}) {
return getElectrumServers(await buildIndex(opts));
}
// ---- on-chain TLD registry (root TLD certificate) ------------------------
// The name ROOT_TLD_NAME carries the activated BCNR-native TLD set in a "tlds"
// record: a space-separated list of TLD suffixes, e.g. "bch p2p deviant sia".
// This is BCNR's IANA-root equivalent — the authoritative list of TLDs the
// network considers native, discovered from the chain so adding a TLD is one
// on-chain UPDATE (no software update, no reinstall). See
// SilentMode/Argus/DESIGN-collision-modes.md for how clients use it (collision
// detection, per-TLD defaults, preempting the soft "Open with…" prompt).
export const ROOT_TLD_NAME = "tlds.bch";
// Shipped bootstrap set — used before the on-chain list is fetched, and as a
// last-resort fallback if the root cert is briefly unreachable. Deliberately
// small: just the TLD you're guaranteed to have.
export const BOOTSTRAP_TLDS = ["bch"];
export function parseTldsRecord(records) {
const raw = records && records.tlds;
if (!raw || typeof raw !== "string") return [];
return raw.split(/\s+/).filter(Boolean)
.map((t) => t.toLowerCase())
.filter((t) => TLD_RE.test(t));
}
// Pull the activated TLDs out of an already-built index (no extra round-trip).
export function getBcnrTlds(index) {
const entry = index && typeof index.get === "function" ? index.get(ROOT_TLD_NAME) : null;
const list = entry ? parseTldsRecord(entry.records) : [];
return list.length ? list : BOOTSTRAP_TLDS.slice();
}
// Convenience: build the index, return the activated TLD set. Empty root
// certificate or unreachable chain -> BOOTSTRAP_TLDS.
export async function fetchBcnrTlds(opts = {}) {
return getBcnrTlds(await buildIndex(opts));
}
// Is this TLD activated on-chain? Case-insensitive.
export function isBcnrNativeTld(tldOrList, tld) {
const list = Array.isArray(tldOrList) ? tldOrList : [tldOrList];
const t = String(tld || "").toLowerCase();
return list.includes(t);
}