Initial commit — Silent Mode baseline (2026-07-29)

Snapshot of the decentralized-web stack at the point of the resolver+Theseus
rebuild deploy. Includes:

- Argus (BNS engine + resolver daemon + Sia gateway)
- AriadneResolver (Windows Inno installer bundle + Android APK sources +
  Firefox extension)
- TheseusNavigator (Electron browser)
- site/ (silentmode.st content, deployed to Sia at bns/silentmode/)
- design docs, roadmap, protocol spec

Secrets excluded via .gitignore: Argus/sia-s3.json, Argus/wallets.json,
Argus/ca/*.key,*.crt. Build outputs, node_modules, and bundled runtimes
also excluded.

Shipped hashes on dl.silentmode.st at this commit:
  AriadneResolver-Setup-0.1.0.exe    5bcb216eef31ea28ed767e4134ab74bd5ac69dfbd365fd249e9e6938e55c986a
  TheseusNavigator-Setup-0.0.1.exe   7c735e88bad2da3347145adba3016c8f626a18b8422289c8c6ba471972e2952b
  TheseusNavigator-0.0.1-portable.exe 008fd84445babeabb401b2bca40ea9466b24b0e6d6c85104da7640c5c5c84521
  ariadne-v0.2.apk                   635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c
This commit is contained in:
Local Dev 2026-07-29 13:54:34 +02:00
commit a45320db7d
56 changed files with 9172 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
wallets.json
faucet.html
ca/
setup/install-state.json
sia-s3.json
tools/
SiaHosting/

76
BROWSER-PROMPT.md Normal file
View file

@ -0,0 +1,76 @@
# Prompt for a new session: build Theseus, the browser
Copy everything below the line into a fresh Claude Code session (suggested
working directory: `D:\Dev\BnsBrowser`).
---
Build **Theseus Navigator** ("Theseus" for short) — a standalone desktop web
browser with native support for decentralized `.bch` domain names, resolved
from the Bitcoin Cash blockchain. Windows 10 first; Electron is the expected
shell (Chromium engine, no forking).
Branding: *Theseus Navigator, by Silent Mode — a Deviant project*. The name refers to the
thread through the labyrinth: the blockchain is the thread. Read
`D:\Dev\SilentMode\README.md` first for the whole picture. `theseus.bch` is
already registered on chipnet and can serve the browser's own homepage.
## Context — a working stack already exists
`D:\Dev\NameCoin` contains a complete, tested prototype (read its README.md,
PROTOCOL.md, and ROADMAP.md first):
- `src/lib/bns.js` — the resolver core: reads the "BNS1" protocol from BCH
chipnet via electrum (mainnet-js). Names are CashTokens name certificates; records are
JSON in OP_RETURN (`ip`, `u`, `p`, `h`, `s3`, `tls` types); discovery via a
beacon address. Subdomains map through the parent name (see bnsd.js).
- `src/daemon/bnsd.js` — reference gateway: serves `h` (on-chain HTML), `p`
(reverse proxy), `s3` (Sia storage via an S3 endpoint configured in
`sia-s3.json`), redirects `u`, and resolves `ip` records at DNS level.
- `ca/` — a local root CA ("BNS Local Root CA") that signs per-name certs;
fingerprints can be pinned via on-chain `tls` records.
- Live test names on chipnet right now: `hello.bch` (on-chain HTML),
`coinspectrum.bch` (ip → real nginx server), `siatest.bch` and
`coinspectrum.deviant.bch` (Sia-backed, subdomain namespace),
`theseus.bch` and `silentmode.bch` (on-chain HTML, brand pages).
Reuse `bns.js` as a library (npm file: dependency or copied module) — do not
reimplement the protocol. The browser must NOT require the system daemon,
NRPT rules, or the OS trust store: everything in-process.
## Requirements
1. **Chrome-like shell**: tabs, address bar, back/forward/reload, history.
Electron BrowserViews/WebContentsViews per tab.
2. **Native .bch resolution**: typing `hello.bch` or `x.deviant.bch` resolves
via the BNS core inside the app (no OS DNS involvement). Everything else
browses the normal web unchanged.
3. **Record handling**: `h` render directly; `ip` connect to the host (http);
`p`/`s3` fetch via an in-app gateway on a loopback port; `u` redirect.
4. **TLS for .bch without touching the OS store**: intercept at the session
level (Electron `setCertificateVerifyProc`) — accept certs for .bch hosts
when they chain to the BNS root or match the name's on-chain `tls`
fingerprint; normal CA validation for everything else.
5. **UI truthfulness**: a visible indicator when a page came from the chain /
Sia / a direct ip record, showing the certificate category and record type — the
"padlock" for decentralized provenance.
6. **Fallback honesty**: unregistered .bch → a clean NXDOMAIN page naming the
chain; electrum outages → readable error, not a hang.
7. Package with electron-builder into a Windows installer (unsigned is fine).
## Definition of done
All four live names above load correctly in tabs alongside normal websites
(e.g. wikipedia.org), with provenance indicators, from a packaged .exe on
Windows 10 — no admin rights, no system daemon, no OS DNS or cert changes.
## Constraints & notes
- BCH chipnet; electrum servers as in mainnet-js defaults (see bns.js usage).
- `sia-s3.json` in D:\Dev\NameCoin holds the S3 endpoint + keys for `s3`
records (server-side s3d at https://coinspectrum.duckdns.org:8600) — copy
it into the browser's config directory; do not commit it.
- Wallet/registration UI is OUT of scope (exists in the NameCoin repo's
wallet app); this session is the *viewing* side only.
- Test names may be unconfirmed on chipnet — the resolver handles mempool
entries; height -1 is normal.

90
DESIGN-hosting-pact.md Normal file
View file

@ -0,0 +1,90 @@
# Argo — the hosting pact: dilution-pool incentives for serving the decentralized web
*Argo is the host network; the hosts who crew it are **Argonauts**. Part of the
Silent Mode stack (hub: `D:\Dev\SilentMode\README.md`).*
Status: design idea (July 2026). Not yet implemented. Companion to
[PROTOCOL.md](PROTOCOL.md); assumes BNS names on BCH and content on a storage
network (Sia, IPFS, or on-chain `h` records).
## The idea in one paragraph
A site owner funds a **pool** tied to their name's content (monthly / quarterly
/ yearly). Hosts **join permissionlessly** — no one selects them — by bonding a
small stake and proving they store/serve the content. Each epoch the pool pays
out, **split among all current provers**. More hosts → thinner slices; fewer
hosts → fatter slices. Replication count is not configured anywhere: it is the
*equilibrium* of pool size vs. hosting cost, discovered continuously by the
market. Paying more is the owner's only knob — and it directly buys more
redundancy and serving capacity. Client software suggests a price from the
median of on-chain host asks.
## How Sia works, for contrast
Sia is **renter-driven, closed-membership**: the customer's agent (`renterd`)
scans the host market, *selects* ~30 specific hosts, forms bilateral fixed-term
contracts with each, monitors them, and migrates shards when hosts die. Hosts
advertise general capacity and wait to be picked; they cannot volunteer for a
specific dataset. Repair requires the renter's agent to be online and funded.
## Why Sia's model is not enough (for websites)
1. **A manager must exist.** Sia heals because *renterd* notices dead hosts
and re-uploads. If the renter agent is offline or unfunded, redundancy
silently decays. The pool heals **without any manager**: a dead host stops
proving → its share flows to the survivors → each slice fattens → the pool
becomes more attractive → new hosts join. The noticing is done by
economics, not by an agent. This is the core automation the idea adds.
2. **Membership is closed.** In Sia, a host that *wants* to help serve a
popular site cannot join its contract set. In the pool, joining is the
default; the algorithm that "decides who hosts" is replaced by
self-selection under price pressure (the same open-entry mechanism as
Bitcoin mining: reward fixed, participants join until marginal profit ~ 0).
3. **No serving incentive.** Sia pays for *storage possession*, never for
*serving the public* — the gap that killed Skynet (decentralized storage,
centralized unpaid portals). The pool's payout can cover gateways/seeders,
the actually-scarce resource for websites.
4. **Designed for private data.** Sia encrypts client-side and hides data from
hosts — right for backups, unnecessary for websites, which are public
goods. Public content is exactly the case where open membership is safe:
there is nothing to leak, and anyone may legitimately serve it.
5. **Lifetime is administrative, not economic.** Sia contracts expire per
contract term. A pool's site lives exactly as long as someone values it
enough to fund it — a market-driven expiry that doubles as the namespace
garbage collection Namecoin lacked (cf. Kalodner et al. 2015).
## Known hard problems (and mitigations)
- **Sybil splitting** — one disk posing as N hosts to collect N shares.
Mitigate: per-host-unique erasure-coded shards (k-of-n), so each identity
must store distinct data; stake per identity; diminishing returns per new
host as slices thin.
- **Proof of possession** — challenge from a future block hash → Merkle proof
over the host's shard. Verifiable in a BCH covenant post-May-2025 limits
(clunky but plausible); v0 can verify in an auditable coordinator.
- **Proof of serving** — unprovable on-chain (true for Filecoin/Sia too).
Layered approach: watchtower spot-checks, client-side reputation, coordinator
attestation; shrink the trusted surface over versions.
- **Pool exhaustion** — funding dries up → hosts leave → site dark. Feature,
not bug (see 5 above), but resolvers should surface "pool low" warnings so
owners/fans can top up (anyone may pay into a pool — patronage built in).
## Sketch on BCH
1. Site → storage network → per-host shards (off-chain tooling).
2. Name's chain record: `{"cid"|"s3": ..., "pact": <pool id>}`.
3. Hosts post asks via beacon txs; wallets suggest the median (owner's knob).
4. Pool = covenant UTXO funded by owner (or anyone); hosts join by bonding
stake in a registration tx naming their shard commitment.
5. Per epoch: block-hash challenge → hosts submit proofs → covenant releases
the epoch tranche split among provers; missed proof = no pay (+ stake slash
on repeated failure).
6. Resolvers discover hosts from registrations and fetch from any of them.
## Version ladder
- **v0** — coordinator does audits + payouts openly on chipnet (trusted but
fully auditable; all money flows on-chain).
- **v1** — possession proofs verified by CashScript covenant; coordinator only
attests serving quality.
- **v2** — multi-watchtower serving attestation, coordinator eliminated.

47
ENGINE.md Normal file
View file

@ -0,0 +1,47 @@
# Argus — the engine
**Argus built the Argo.** This folder is the shared core of the Silent Mode
stack — the protocol implementation and reference tooling that every product
(Ariadne the resolver, Theseus the browser, Argo the host network) is built
from. Named for the shipwright, guided by Athena, who built the ship the
Argonauts sailed.
It is a **clone of `D:\Dev\NameCoin`**, brought into the SilentMode tree.
## ⚠ Authority during the transition (read this)
`D:\Dev\NameCoin` is still **LIVE / canonical** right now:
- the installed Windows resolver task points at `NameCoin\src\daemon\bnsd.js`;
- the VPS Sia auto-sync and `sia-s3.json` endpoint are tied to it;
- its `wallets.json` and `ca/` are the authoritative keys.
**Rules until migration is finished:**
- Use **Argus for development** of new updates (safe: reads are harmless).
- Do **not** run `setup/install.ps1` from Argus yet (it would re-point the live
task at this copy before you intend it).
- Do **not** register/update names from Argus while NameCoin is also live — both
copies share the same chipnet wallet UTXOs; concurrent spends conflict.
- Treat NameCoin's `wallets.json` / `ca/root-ca.key` as the single source of
truth for keys. The copies here are a snapshot and may go stale.
## Migration plan (step by step, later — after shipping updates)
1. Develop and test new features here in Argus.
2. When ready to flip: stop using NameCoin, refresh Argus's `wallets.json`,
`ca/`, `sia-s3.json` from NameCoin (so keys are current), then run
`setup/install.ps1` **from Argus** (elevated) to re-point the resolver task.
3. Verify all live names still resolve + serve (padlock intact).
4. Update the VPS: no change needed unless the S3 endpoint moves.
5. Retire `D:\Dev\NameCoin` (archive, don't delete, until confident).
## What's inside
Same layout as the reference README (`README.md` in this folder). Key files:
- `src/lib/bns.js` — protocol core (codec, indexer/resolver, tx builders)
- `src/daemon/bnsd.js` — DNS + HTTP/HTTPS gateway (h/p/s3/ip, subdomains, TLS)
- `src/lib/ca.js`, `src/lib/issue-cert.js` — the BNS local CA
- `setup/install.ps1`, `setup/uninstall.ps1` — Windows integration
- `src/register.js` / `update.js` / `resolve.js` — CLI
- `src/wallet-app.js` — wallet with embedded browser
- `PROTOCOL.md`, `ROADMAP.md`, `DESIGN-hosting-pact.md`, `BROWSER-PROMPT.md`
Verified: `npm install` + `node src/resolve.js` lists all live chipnet names.

122
OPERATIONS-electrum.md Normal file
View file

@ -0,0 +1,122 @@
# Running your own Fulcrum — and why the resolver wants you to
Referenced from `src/registrar-config.js` (`REGISTRAR.selfHosted`).
## The problem this solves
Public electrum servers listen on **non-standard ports**: 50004 and 62002. On
2026-07-27 all three configured chipnet servers went TCP-closed from one machine
at the same instant, while `:443` stayed perfectly reachable. That is the
signature of a firewall or VPN policy, not an outage — and it is the normal
condition on corporate networks, on some mobile carriers, and behind many
consumer VPNs.
The consequence is worse than it sounds. Every server in the list shares the same
two ports, so **one rule takes down the entire resolver**: no availability
search, no registration, no record updates, for that user, everywhere the
software runs — website, extension, Theseus, Ariadne.
A server on 443 is indistinguishable from ordinary HTTPS traffic and is
essentially never blocked. **One server on 443 is worth more than three more on
50004.**
## The second reason: not trusting one server
A single electrum server is a trusted third party. It cannot forge a
registration — that needs a real CashTokens genesis whose commitment matches the
name — but it can **omit** one. Because the protocol rule is *"first valid REG
wins"*, a hidden REG makes a taken name look available: the buyer pays, mints a
duplicate certificate, and every other resolver rejects it.
`ElectrumPool.unionHistory` defends against exactly this by taking the **union**
of beacon history across every server that answers, and reporting which servers
were short. Union rather than quorum, because omission is the attack: a server
showing you *more* history is giving you more truth, and two stale servers must
not be able to outvote one honest one.
**So running your own server is not a centralisation trade.** Your server is the
fast, unblockable path; the public ones stay in the list as cross-checks. The
union means your own server cannot quietly hide a registration from you either.
## Configuration
```js
// src/registrar-config.js
selfHosted: "wss://bns.example.st:443",
```
That is all. The pool connects in parallel, prefers earlier entries, fails over
automatically, and unions the history regardless of order.
## Running Fulcrum
Fulcrum indexes a full node; you need both.
1. **Bitcoin Cash Node** with the indexes Fulcrum needs:
```
txindex=1
server=1
rpcuser=…
rpcpassword=…
chipnet=1 # or omit for mainnet
```
2. **Fulcrum**, pointed at it. The part that matters here is the **WSS port on
443**:
```
bitcoind = 127.0.0.1:8332
rpcuser = …
rpcpassword = …
ssl = 0.0.0.0:50002
wss = 0.0.0.0:443 # <- the one that survives restrictive networks
cert = /etc/letsencrypt/live/bns.example.st/fullchain.pem
key = /etc/letsencrypt/live/bns.example.st/privkey.pem
```
3. **A real certificate.** Browsers will not open a `wss://` connection with a
self-signed cert, and the failure is opaque — it surfaces as a generic
WebSocket error with no indication that the certificate is the cause. Use
Let's Encrypt.
4. **Binding 443 needs privilege.** Either `setcap
'cap_net_bind_service=+ep'` on the Fulcrum binary, or terminate TLS at nginx
and proxy to Fulcrum's plain WS port. The nginx route is usually easier
because it shares 443 with the rest of the site:
```nginx
location /electrum {
proxy_pass http://127.0.0.1:50003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # electrum connections are long-lived
}
```
Then `selfHosted: "wss://bns.example.st:443/electrum"`.
## Verifying it
```bash
# reachable at all?
curl -sk -o /dev/null -w "%{http_code}\n" https://bns.example.st:443
# does it speak electrum?
cd Argus && node --input-type=module -e "
import WebSocket from 'ws';
import { Electrum } from './src/lib/electrum.js';
const el = await new Electrum('wss://bns.example.st:443', WebSocket).connect();
console.log(await el.call('server.version', ['probe','1.4.2']));
console.log('height:', (await el.call('blockchain.headers.subscribe')).height);
el.close(); process.exit(0);"
```
The second command is the one that matters — a server can answer HTTPS and still
not be a working electrum endpoint.
## Note for the browser
The register page can only reach electrum over **WSS**. It cannot open raw TCP,
so it can never talk to a P2P node directly, and it cannot use a plain-TCP
electrum port. Every entry in the list must be `wss://`. This is also why
SPV-grade *verification* (headers + merkle proofs over electrum) is the right
target for the web client rather than direct-to-node SPV — see
`Decentralized.DNS/DESIGN-trustless-purchase.md`.

95
PROTOCOL.md Normal file
View file

@ -0,0 +1,95 @@
# BNS1 — a minimal Namecoin-style name protocol on Bitcoin Cash (chipnet prototype)
Names are anchored by **CashTokens name certificates** on the BCH chain; records travel in
**OP_RETURN** payloads; discovery uses a **beacon address** so a plain electrum
server is the only infrastructure a resolver needs. No fork, no sidechain.
## Roles of the three mechanisms
| Mechanism | Role |
|---|---|
| CashTokens certificate (immutable NFT) | *Ownership.* The certificate's commitment is the UTF-8 name. Whoever holds the certificate owns the name. Consensus guarantees nobody can mint an output of an existing category without spending it. |
| OP_RETURN payload | *Data.* `BNS1` prefix + JSON: the name's records (URL, inline HTML page, payment address). |
| Beacon dust output | *Discovery.* Every protocol tx pays 600 sat to the well-known beacon address, so `blockchain.address.get_history(beacon)` enumerates every registration/update ever made. |
**Beacon (chipnet):** `bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl`
## Transactions
**REG** — one tx that simultaneously:
1. Mints an immutable certificate, commitment = UTF-8 name (≤ 40 bytes). Its category
(= token ID) becomes the name's permanent identity.
2. Carries `OP_RETURN "BNS1{...json...}"` with `{"o":"REG","n":<name>,"r":<records>}`.
3. Pays 600 sat to the beacon.
**UPD** — one tx that:
1. Spends (moves) the name's certificate back to the owner — this is the ownership proof.
A vout carrying the name's category is only possible if a vin held it (consensus).
2. Carries `OP_RETURN "BNS1{...}"` with `{"o":"UPD","n":<name>,"r":<newRecords>}`.
3. Pays 600 sat to the beacon.
Transferring a name = sending the certificate to a new owner (no beacon tx needed).
## Records (JSON, keep total payload ≤ 200 bytes)
- `u` — https URL the name points to (gateway redirects)
- `h` — inline HTML: a tiny website stored fully on-chain
- `a` — cashaddr for payments (the "send to alice.bch" use case)
- `ip` — IPv4 of a server that answers for the .bch host directly (DNS answers
this instead of the local gateway)
- `p` — upstream URL the local gateway reverse-proxies (address bar stays .bch)
- `s3` — `bucket/key` (single object) or `bucket/prefix/` (site root) stored on
the Sia network, served by the gateway through a local s3d daemon
(credentials in sia-s3.json, not on-chain). Storage is decentralized and
encrypted; the retrieval door is the local daemon.
- `tls` — SHA-256 fingerprint (hex, over DER) of the name's TLS certificate.
The chain replaces the CA: identity comes from the certificate holder publishing
this record, encryption from ordinary TLS. Resolvers/extensions can pin it;
the prototype's trust path is the BNS Local Root CA (see ca/) which signs
per-name certs and is installed as a trust anchor by setup/install.ps1.
## Resolver rules (indexer-enforced)
1. Fetch beacon history, sort by `(height, txid)`; unconfirmed last.
2. **Then reorder causally:** never index a transaction before one it spends
from. Independent transactions keep the `txid` tiebreak. *(See "Ordering"
below — this rule is load-bearing, added 2026-07-26.)*
3. `REG`: valid if the same tx mints a certificate whose commitment matches the name
and the name is not yet taken. **First valid REG wins.**
4. `UPD`: valid if some output of the tx carries the name's category.
5. Names: `[a-z0-9-]{1,32}`, rendered as `<name>.bch`.
### Ordering (why rule 2 exists)
`(height, txid)` alone is arbitrary within a block and within the mempool, and
that breaks the most ordinary sequence in the protocol: **register a name, then
immediately point it somewhere.** If the `UPD`'s txid happens to sort below its
own `REG`'s, the `UPD` is evaluated against an index that does not contain the
name yet, fails rule 4, and is silently dropped — *permanently*, if both land in
the same block, since the ordering never changes afterwards.
Observed on chipnet 2026-07-26: `REG ca243c06…` for `smtest-fbe8cd.p2p` and its
`UPD 8da2cc4a…` were both unconfirmed; `8da2…` sorted first, and the record
never appeared even though the update transaction was entirely valid.
The fix is a stable topological pass over the history: keep the `(height, txid)`
baseline, but hold back any transaction whose parents (within the beacon set)
have not been emitted yet. Cost is O(n²) over the beacon history, which is read
once per index build. Implemented identically in `src/lib/resolver-web.js` and
`src/lib/bns.js` — **the two must stay in lockstep**, since a disagreement
between them is a consensus split between clients.
## Honest limitations (prototype vs production)
- Uniqueness is **indexer policy**, not consensus. Production: a covenant-run
registry (CashScript) that consensus-enforces one certificate per name, or full
BCMR-style client-side validation of the genesis chain.
- Intra-block ordering uses txid as tiebreak among *independent* transactions
(electrum history has no tx index). Dependent transactions are ordered
causally — see "Ordering" above.
- The beacon is a keyed address (prototype convenience); production would use a
keyless constant script so no one can be pressured over it.
- No expiry/renewal: names live forever once minted (Namecoin used ~8-month
renewals to garbage-collect dead names).
- Front-running: a miner/mempool observer could see a REG and race it
(Namecoin's two-step `name_new` commit-reveal solved this; omitted here).

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# BNS — Namecoin-style decentralized names on Bitcoin Cash (chipnet)
A working prototype of "Option 3": names are **CashTokens name certificates** on the BCH
chain (no fork, no sidechain), records ride in OP_RETURN, discovery via a
beacon address, and the resolver is **embedded in a wallet app** with a
built-in browser pane — the Brave/MetaMask-browser architecture in miniature.
See [PROTOCOL.md](PROTOCOL.md) for the on-chain design and its honest limitations.
## Layout
- `src/lib/bns.js` — protocol core: payload codec, chain indexer/resolver, tx builders
- `src/wallet-app.js` — wallet + embedded resolver + browser UI (`npm run wallet` → http://localhost:4626)
- `src/register.js` / `src/update.js` / `src/resolve.js` — CLI equivalents
- `wallets.json` — chipnet seed phrases (TESTNET ONLY — never reuse for real funds)
- `src/lib/gen-wallets.js` — one-time wallet generation (already run)
## Setup
1. `npm install`
2. Fund the wallet with free chipnet tBCH: go to https://tbch.googol.cash/,
paste the wallet address (shown in the app sidebar or by
`node src/lib/gen-wallets.js`), pick **chipnet**, solve the captcha.
~0.001 tBCH funds hundreds of registrations.
3. `npm run wallet` and open http://localhost:4626
## Standalone resolver daemon (make EVERY browser resolve .bch)
- `src/daemon/bnsd.js` — DNS server (answers `*.bch` from the chain, forwards
everything else upstream) + HTTP gateway that serves on-chain sites by Host
header. Test unprivileged: `node src/daemon/bnsd.js --dns-port 5354 --http-port 8081`,
then `node src/daemon/dig.js hello.bch`.
- `setup/install.ps1` — run from an **elevated** PowerShell. Installs the daemon
as a boot-time scheduled task (SYSTEM, auto-restart) and wires the OS to it:
- default (`-Mode nrpt`): adds an NRPT rule so only `.bch` queries reach the
daemon; all other DNS is untouched.
- `-Mode full`: points every adapter's DNS at 127.0.0.1 (daemon forwards
non-.bch upstream, ncdns-style); original servers are saved and restored
on uninstall.
- `setup/uninstall.ps1` — reverses everything (rule/adapters/task).
HTTPS for .bch: the "BNS Local Root CA" (created on demand in `ca/`, keep
`root-ca.key` private) signs per-name certs. The daemon's :443 gateway mints
them on the fly for locally-served names; `node src/lib/issue-cert.js <name>`
issues one for a real server (see the coinspectrum.bch nginx vhost). The
installer trusts the root machine-wide and enables Firefox's
enterprise-roots policy; cert fingerprints are published in "tls" chain
records for pinning.
Caveats: browsers with "secure DNS" (DoH) enabled bypass the OS resolver —
disable it or add an exception; ports 53/80/443 must be free.
## Try it
- Register `hello` with inline HTML (a website stored entirely on-chain),
then type `hello.bch` in the address bar — the app resolves it from the
blockchain (not DNS) and renders it.
- Or CLI: `node src/register.js hello '{"h":"<h1>Served from the BCH chain</h1>"}'`
then `node src/resolve.js hello`

80
ROADMAP.md Normal file
View file

@ -0,0 +1,80 @@
# BNS Roadmap — from prototype to a permissionless web stack
*Silent Mode (a Deviant division). Hub: `D:\Dev\SilentMode\README.md`.
Products: **Theseus Navigator** (browser), publisher app, resolver daemon,
**Argo** (host network, crewed by **Argonauts**), **Argonautica** (discovery).*
The four moving parts (see [DESIGN-hosting-pact.md](DESIGN-hosting-pact.md),
[PROTOCOL.md](PROTOCOL.md)): **chain** for ownership, **storage** for bulk,
**gossip** for liveness, **pool** for incentives. Phases are ordered by
dependency, not calendar; tracks 5 (browser) and 2 can run in parallel with
everything.
## Phase 0 — Prototype (DONE, July 2026)
Names as CashTokens name certificates on BCH chipnet; OP_RETURN records; beacon discovery;
resolver daemon + NRPT/adapter installer; HTTPS via local root CA with
on-chain fingerprint pinning; wallet app with embedded browser; real sites
served three ways (own server via ip record, reverse proxy, Sia storage via
s3d with server-side authority + 6h auto-sync); subdomain namespaces
(deviant.bch); dilution-pool design written.
## Phase 1 — Harden the name layer (protocol v2)
- Expiry + renewals (anti-squatting garbage collection; Kalodner 2015)
- Graduated pricing: short/dictionary names cost more (ENS lesson)
- Commit-reveal registration (front-running protection, Namecoin's name_new)
- CashScript covenant registry: consensus-enforced uniqueness (novel on BCH)
- Constrained CA (X.509 name constraints limited to .bch) + short-lived leaf certs
- Mainnet port (config only: network + electrum servers + real BCH)
## Phase 2 — Distribution (reach other machines)
- Firefox extension (Mozilla-signed, self-distributed): intercepts .bch,
routes via resolver/gateway — no DNS surgery, no DoH conflict
- Chromium extension (store review)
- Signed Windows installer; macOS/Linux daemon ports
- LAN mode: daemon answers with host-LAN IP so phones/TVs resolve .bch
- Android: resolver+gateway as an app (sideload APK first)
## Phase 3 — Publisher app ("the missing UX")
One window: pick folder → pick name → pay → site is live. Under the hood:
- Sia Storage SDK (or s3d) for storage; BNS txs for the name
- Auto-sync scheduling (generalize the coinspectrum systemd timer)
- BCH-denominated plans (Architecture A): user pays BCH; app funds storage
via Sia's market; BCH surplus funds the serving layer
- This is where ordinary users enter; everything before it is plumbing
## Phase 4 — Argonautica: dynamic discovery (live sites without servers)
- Chain record gains the owner's signing pubkey
- Site updates = feed of author-signed records (ZeroNet/Nostr pattern);
resolvers verify signatures and apply updates — no chain tx per update
- Multi-author records (comments, reactions) as CRDT merges (add-wins sets)
- Law to respect: gossip only for conflict-free facts; anything "exactly-one"
stays on the chain
## Phase 5 — Argo: the hosting pact (dilution pool, Architecture B)
- v0: coordinator audits + pays openly on chipnet; hosts run our host agent;
per-host-unique erasure shards (sybil resistance); stake bonding
- v1: possession proofs (block-hash challenge → Merkle proof) verified by a
CashScript covenant; coordinator only attests serving quality
- v2: watchtower attestation replaces coordinator; open-join equilibrium
replication fully market-driven
- Result: BCH-native host network; Sia becomes one backend among several
## Track 5 (parallel) — Theseus Navigator, the independent browser
See BROWSER-PROMPT.md — built in its own session/repo (`D:\Dev\BnsBrowser`),
consuming this repo's resolver core as a library. `theseus.bch` is registered
and should eventually serve the browser's own homepage over BNS itself.
## Standing risks
- Chromium ignores NRPT → extension/full-mode/browser are the mitigations
- s3d instances don't share object indexes → one authority instance per account
- .bch collision with a future ICANN gTLD → long-term: IETF special-use
reservation (the .onion path)
- Root CA key custody until name constraints land

17
ariadne.config.json Normal file
View file

@ -0,0 +1,17 @@
{
"_comment": "Ariadne / BCNR resolver settings. CLI flags override these values. Edit and restart the daemon (or the 'BNS Resolver Daemon' service) to apply. A settings UI will edit this file.",
"upstream": "1.1.1.1",
"dnsPort": 53,
"httpPort": 80,
"httpsPort": 443,
"gateway": "https://navigate.st",
"_bind": "Listen address for the HTTP/HTTPS gateway. Loopback by default: on an end-user machine this is a LOCAL resolver, and binding every interface exposes it to the whole LAN. A public gateway sets this to 0.0.0.0 deliberately.",
"bind": "127.0.0.1",
"_dnsBind": "Listen address for the DNS server, kept separate from 'bind' on purpose. A recursive resolver reachable from the internet is a DDoS amplification relay, so opening the gateway must never open DNS as a side effect. Leave this on loopback.",
"dnsBind": "127.0.0.1",
"_tlds": "TLDs the local CA may issue TLS certificates for. Resolution stays TLD-agnostic; only issuance is enumerated, because the root CA is name-constrained and X.509 constraints need a finite list. CHANGING THIS REQUIRES REGENERATING THE ROOT CA and re-importing it on every machine (uninstall.ps1 -> delete ca/ -> install.ps1); until then clients reject certificates for the added TLDs. Never add an ICANN-delegated TLD. Omit this key to use the built-in default in src/lib/tlds.js.",
"tlds": ["bch", "p2p", "bit", "deviant", "nav", "test", "bcnr"]
}

186
build-web.mjs Normal file
View file

@ -0,0 +1,186 @@
// Build the browser bundles for the public register page.
//
// SECURITY.md rule 3 applies: no source maps, no build-machine paths, no
// identity strings in anything shipped. This script bundles, then greps the
// output for the things that must never leave the machine (rule 0) and for
// home-directory fingerprints, and refuses to write a bundle that trips either.
//
// Run: node build-web.mjs
import { build } from "esbuild";
import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = dirname(fileURLToPath(import.meta.url));
const siteJs = join(root, "..", "site", "js");
mkdirSync(siteJs, { recursive: true });
// Structural fingerprints that must never appear in a shipped artifact
// (SECURITY.md rule 3). Deliberately NOT matching bare words like "secretKey":
// every crypto library in the tree uses that as a local identifier, and a check
// that cries wolf on nostr-tools is a check people learn to ignore.
const FORBIDDEN = [
/C:\\Users\\[A-Za-z0-9._-]+/i,
/\/home\/[A-Za-z0-9._-]+\//,
/\/Users\/[A-Za-z0-9._-]+\//,
/\bsia-s3\.json\b/i,
/\bwallets\.json\b/i,
/\broot-ca\.key\b/i,
/BEGIN [A-Z ]*PRIVATE KEY/,
];
// The check that actually matters (rule 0): read the real secrets and prove
// none of their VALUES made it into the bundle. Values, not variable names.
// Nothing is ever printed — only whether a match occurred.
function realSecretValues() {
const values = [];
const collect = (v) => {
if (typeof v === "string" && v.length >= 8) values.push(v);
else if (v && typeof v === "object") Object.values(v).forEach(collect);
};
for (const f of ["sia-s3.json", "wallets.json", "setup/install-state.json"]) {
try { collect(JSON.parse(readFileSync(join(root, f), "utf8"))); } catch { /* absent is fine */ }
}
try {
const ca = readFileSync(join(root, "ca", "root-ca.key"), "utf8");
values.push(...ca.split("\n").filter((l) => l.length > 20));
} catch { /* absent is fine */ }
return values;
}
const SECRETS = realSecretValues();
// libauth is shared by every bundle via the page's import map — see
// src/web/libauth-entry.js for why this must not be duplicated.
const SHARED = ["@bitauth/libauth"];
const targets = [
{
entry: join(root, "src", "web", "libauth-entry.js"),
out: join(siteJs, "libauth.js"),
minify: true,
note: "libauth — MIT, shared by every bundle (import map target)",
},
{
entry: join(root, "src", "web", "register-entry.js"),
out: join(siteJs, "bns-register.js"),
minify: true,
external: SHARED,
note: "registration core — MIT, safe to minify",
},
{
entry: join(root, "src", "lib", "connect", "wizardconnect.js"),
out: join(siteJs, "wizardconnect.js"),
// NOT MINIFIED, ON PURPOSE. @wizardconnect/* is LGPL-3.0-or-later; §4(d)
// requires that a user be able to swap in their own build of the library.
// Shipping it as a separate, readable ES module satisfies §4(d)(1) ("a
// suitable shared library mechanism") with no relink paperwork. Minifying
// or folding this into the main bundle would break that. See
// Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md.
minify: false,
external: SHARED,
note: "WizardConnect — LGPL-3.0-or-later, kept separate and unminified (§4d1)",
licences: ["lgpl-3.0", "gpl-3.0"],
},
];
let failed = false;
for (const t of targets) {
await build({
entryPoints: [t.entry],
outfile: t.out,
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
minify: t.minify,
external: t.external ?? [],
sourcemap: false,
legalComments: "none",
// Keep the build reproducible and path-free.
absWorkingDir: root,
define: { "process.env.NODE_ENV": '"production"' },
});
const code = readFileSync(t.out, "utf8");
for (const re of FORBIDDEN) {
const hit = code.match(re);
if (hit) {
console.error(`REFUSING ${t.out}: contains "${hit[0]}" (SECURITY.md rule 3)`);
failed = true;
}
}
for (const secret of SECRETS) {
if (code.includes(secret)) {
// Never echo the value.
console.error(`REFUSING ${t.out}: contains a live secret value (SECURITY.md rule 0)`);
failed = true;
break;
}
}
// LGPL §4(b)/(c): the licence texts must travel with the distribution.
for (const l of t.licences ?? []) {
const path = join(root, "..", "site", "vendor", `LICENSE.${l}.txt`);
try {
if (statSync(path).size < 1000) throw new Error("too small");
} catch {
console.error(`REFUSING ${t.out}: site/vendor/LICENSE.${l}.txt is missing — LGPL §4(b) requires it`);
failed = true;
}
}
const size = statSync(t.out).size;
const sha = createHash("sha256").update(readFileSync(t.out)).digest("hex");
console.log(`${t.out.replace(root, ".")} ${(size / 1024).toFixed(1)} kB`);
console.log(` sha256 ${sha}`);
console.log(` ${t.note}`);
}
// ---------------------------------------------------------------------------
// Verify the shared-libauth contract.
//
// libauth-entry.js re-exports a hand-picked list so tree-shaking still works.
// The risk is shipping a bundle (or a dependency inside one) that imports a name
// the shared module does not export — a runtime failure in a path unit tests
// cannot reach. So: read the imports esbuild actually emitted and prove every
// one of them is satisfied.
// ---------------------------------------------------------------------------
{
const shared = readFileSync(join(siteJs, "libauth.js"), "utf8");
// The final `export{a as b,...}` of the emitted ESM module.
const provided = new Set(
[...shared.matchAll(/export\s*\{([^}]*)\}/g)]
.flatMap((m) => m[1].split(","))
.map((part) => part.trim().split(/\s+as\s+/).pop())
.filter(Boolean),
);
const required = new Map(); // name -> which bundle wants it
for (const t of targets) {
if (t.out.endsWith("libauth.js")) continue;
const code = readFileSync(t.out, "utf8");
for (const m of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']@bitauth\/libauth["']/g)) {
for (const part of m[1].split(",")) {
// `import {a as b}` — `a` is the name the shared module must export.
const name = part.trim().split(/\s+as\s+/)[0].trim();
if (name) required.set(name, t.out);
}
}
}
const missing = [...required].filter(([name]) => !provided.has(name));
if (missing.length) {
for (const [name, who] of missing) {
console.error(`MISSING from src/web/libauth-entry.js: "${name}" (needed by ${who.replace(root, ".")})`);
}
failed = true;
} else {
console.log(`\nshared libauth: ${provided.size} exports, all ${required.size} imports satisfied`);
}
}
if (failed) process.exit(1);
console.log("bundles clean (no secrets, no build-path fingerprints)");

BIN
namespaces.pdf Normal file

Binary file not shown.

2004
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "namecoin",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"wallet": "node src/wallet-app.js",
"register": "node src/register.js",
"resolve": "node src/resolve.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.1093.0",
"@bitauth/libauth": "^3.1.0-next.8",
"@wizardconnect/core": "^0.2.4",
"@wizardconnect/dapp": "^0.2.2",
"express": "^5.2.1",
"mainnet-js": "^4.0.0-next.13",
"node-forge": "^1.4.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}

163
setup/install.ps1 Normal file
View file

@ -0,0 +1,163 @@
# BNS resolver daemon installer — run from an ELEVATED PowerShell.
# .\setup\install.ps1 # surgical: NRPT rule routes only .bch to the daemon
# .\setup\install.ps1 -Mode full # universal: adapter DNS -> 127.0.0.1, daemon forwards the rest
param(
[ValidateSet("nrpt", "full")] [string]$Mode = "nrpt",
[string]$Upstream = "1.1.1.1",
# Opt-in: turn off Firefox DNS-over-HTTPS. Only needed to resolve names that
# ALSO exist in ICANN (a reclaimed .de) via BCNR in Firefox — BCNR-only names
# (all .bch, etc.) already work with DoH on, because DoH returns NXDOMAIN and
# Firefox falls back to the local resolver. Trade-off: Firefox's other DNS then
# goes through the system resolver instead of encrypted to Cloudflare.
[switch]$FirefoxNoDoH,
# Opt-in: force-close a running Firefox so it reloads the new root CA + policy
# immediately (Firefox only reads them at startup). Off by default because it
# discards the user's open tabs/unsaved data.
[switch]$CloseFirefox
)
$ErrorActionPreference = "Stop"
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "Run this from an elevated (Administrator) PowerShell."
}
$root = Split-Path -Parent $PSScriptRoot
# Prefer a bundled Node runtime (distributable installer ships runtime\node.exe);
# fall back to a Node on PATH for running straight from the dev repo.
$node = Join-Path $root "runtime\node.exe"
if (-not (Test-Path $node)) {
$onPath = Get-Command node -ErrorAction SilentlyContinue
if (-not $onPath) { throw "No Node runtime found: expected runtime\node.exe (bundle) or node on PATH (dev repo)." }
$node = $onPath.Source
}
$daemon = Join-Path $root "src\daemon\bnsd.js"
$stateFile = Join-Path $PSScriptRoot "install-state.json"
# Generate the name-constrained local root CA now (on THIS machine; the key never
# ships) so the trust-store import below always finds a valid, constrained root
# instead of racing the daemon's first request.
Push-Location $root
& $node --input-type=module -e "import('./src/lib/ca.js').then(m=>m.loadOrCreateRootCA()).catch(e=>{console.error(String(e));process.exit(1)})"
Pop-Location
# 0) Idempotency: clear any previous install of ours first
Get-DnsClientNrptRule | Where-Object Comment -eq "BNS .bch resolver" | ForEach-Object {
Remove-DnsClientNrptRule -Name $_.Name -Force
}
try { Stop-ScheduledTask -TaskName "BNS Resolver Daemon" -ErrorAction Stop } catch {}
try { Stop-ScheduledTask -TaskName "BNS Sia Bridge" -ErrorAction Stop } catch {}
# 1) Scheduled task: run the daemon at boot as SYSTEM (survives logoff, binds port 53/80/443)
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$daemon`" --upstream $Upstream" -WorkingDirectory $root
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
Register-ScheduledTask -TaskName "BNS Resolver Daemon" -Action $action -Trigger $trigger -Settings $settings `
-User "SYSTEM" -RunLevel Highest -Force | Out-Null
Start-ScheduledTask -TaskName "BNS Resolver Daemon"
Write-Host "[ok] daemon installed as scheduled task 'BNS Resolver Daemon' and started"
# 1b) Sia bridge (optional): serves "s3" records from the Sia network
$s3d = Join-Path $root "tools\s3d.exe"
if (Test-Path $s3d) {
# runs as the logged-on user (s3d's login state lives in the user's APPDATA)
$s3dAction = New-ScheduledTaskAction -Execute $s3d -WorkingDirectory $root
$s3dTrigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$s3dPrincipal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
Register-ScheduledTask -TaskName "BNS Sia Bridge" -Action $s3dAction -Trigger $s3dTrigger `
-Settings $settings -Principal $s3dPrincipal -Force | Out-Null
Start-ScheduledTask -TaskName "BNS Sia Bridge"
Write-Host "[ok] s3d Sia bridge installed (runs at your logon) and started"
}
# 2) Route queries to it
$state = @{ mode = $Mode; adapters = @() }
if ($Mode -eq "nrpt") {
Add-DnsClientNrptRule -Namespace ".bch" -NameServers "127.0.0.1" -Comment "BNS .bch resolver"
Write-Host "[ok] NRPT rule added: .bch -> 127.0.0.1 (all other DNS untouched)"
} else {
# Reinstall-safe backup: if the machine is ALREADY pointed at us (127.0.0.1) from a
# prior install, the "current" servers are a useless restore target. Reuse the last
# good backup if we have one, else fall back to DHCP. NEVER record loopback as the
# original — that's exactly what left a past uninstall pointing DNS at a dead daemon.
$prior = @{}
if (Test-Path $stateFile) {
try { (Get-Content $stateFile | ConvertFrom-Json).adapters | ForEach-Object { $prior[$_.alias] = $_ } } catch {}
}
foreach ($if in Get-DnsClientServerAddress -AddressFamily IPv4 |
Where-Object { $_.InterfaceAlias -notmatch "Loopback" -and $_.ServerAddresses.Count -gt 0 }) {
$orig = @($if.ServerAddresses | Where-Object { $_ -ne '127.0.0.1' -and $_ -ne '::1' })
if ($orig.Count -eq 0 -and $prior.ContainsKey($if.InterfaceAlias)) {
$orig = @($prior[$if.InterfaceAlias].servers | Where-Object { $_ -and $_ -ne '127.0.0.1' -and $_ -ne '::1' })
}
$state.adapters += @{ alias = $if.InterfaceAlias; servers = $orig; resetToDhcp = ($orig.Count -eq 0) }
Set-DnsClientServerAddress -InterfaceAlias $if.InterfaceAlias -ServerAddresses 127.0.0.1
$restoreTo = if ($orig.Count) { $orig -join ', ' } else { 'DHCP (automatic)' }
Write-Host "[ok] adapter '$($if.InterfaceAlias)' DNS -> 127.0.0.1 (restore target: $restoreTo)"
}
}
# 3) TLS trust anchor: BNS Local Root CA into the machine store, and tell
# Firefox (which keeps its own store) to honor Windows enterprise roots.
$caCert = Join-Path $root "ca\root-ca.crt"
if (Test-Path $caCert) {
# Refuse to trust a root that can vouch for the whole internet. Roots generated
# before 2026-07-26 carry no name constraints; installing one means anyone who
# can read ca\root-ca.key can forge a trusted certificate for any domain on this
# machine. Constrained roots are limited to the TLDs in src\lib\tlds.js.
$ncOid = "2.5.29.30"
$parsed = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($caCert)
$hasConstraints = @($parsed.Extensions | Where-Object { $_.Oid.Value -eq $ncOid }).Count -gt 0
$parsed.Dispose()
if (-not $hasConstraints) {
Write-Host ""
Write-Host "[STOP] ca\root-ca.crt has NO X.509 name constraints." -ForegroundColor Red
Write-Host " Trusting it would let anyone who reads ca\root-ca.key forge a" -ForegroundColor Red
Write-Host " certificate for ANY domain on this machine, not just BNS names." -ForegroundColor Red
Write-Host " Rotate the CA, then re-run this installer:" -ForegroundColor Red
Write-Host " setup\uninstall.ps1 (removes the old root from the store)" -ForegroundColor Red
Write-Host " Remove-Item -Recurse $($root)\ca"
Write-Host " setup\install.ps1 (generates a constrained root)" -ForegroundColor Red
Write-Host ""
throw "refusing to install an unconstrained root CA"
}
$imported = Import-Certificate -FilePath $caCert -CertStoreLocation Cert:\LocalMachine\Root
$state.caThumbprint = $imported.Thumbprint
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" `
-Name "ImportEnterpriseRoots" -Value 1 -Type DWord
# Also install our specific root into Firefox's own store (works even if the
# user disabled enterprise-roots). Both policies are applied at Firefox startup.
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates\Install" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates\Install" -Name "1" -Value $caCert -Type String
Write-Host "[ok] BNS Local Root CA trusted (machine store + Firefox enterprise-roots & install policies)"
# Firefox only loads the enterprise-roots policy + new root at startup, so a
# running Firefox keeps rejecting .bch until it is restarted.
$ff = Get-Process firefox -ErrorAction SilentlyContinue
if ($ff) {
if ($CloseFirefox) {
$ff | Stop-Process -Force
Write-Host "[ok] Firefox was closed — reopen it and .bch certificates will be trusted"
} else {
Write-Host "[!] Firefox is open — fully QUIT and reopen it so it trusts .bch (or re-run with -CloseFirefox)." -ForegroundColor Yellow
}
}
} else {
Write-Host "[skip] no ca\root-ca.crt found — https for .bch not enabled"
}
# 3b) Firefox DNS-over-HTTPS. BCNR-only names (all .bch, etc.) already work in
# Firefox with DoH ON — DoH returns NXDOMAIN and Firefox falls back to the
# system resolver (us). Only names that ALSO exist in ICANN (a reclaimed .de)
# lose to the real record. Turning DoH off fixes those but sends Firefox's
# other DNS through the system resolver instead of encrypted to Cloudflare —
# so it's opt-in (-FirefoxNoDoH) and left unlocked so the user can re-enable.
if ($FirefoxNoDoH) {
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Name "Enabled" -Value 0 -Type DWord
Write-Host "[ok] Firefox DNS-over-HTTPS disabled (opt-in) — ICANN-conflicting names now resolve via BCNR in Firefox too"
} else {
Write-Host "[info] Firefox DoH left on (BCNR-only names work anyway). To also reclaim names that exist in ICANN, re-run with -FirefoxNoDoH"
}
$state | ConvertTo-Json -Depth 5 | Set-Content $stateFile
Clear-DnsClientCache
Write-Host "[done] try: https://hello.bch (uninstall: .\setup\uninstall.ps1)"

62
setup/uninstall.ps1 Normal file
View file

@ -0,0 +1,62 @@
# Reverses install.ps1 — run from an ELEVATED PowerShell.
$ErrorActionPreference = "Stop"
$stateFile = Join-Path $PSScriptRoot "install-state.json"
Get-DnsClientNrptRule | Where-Object Comment -eq "BNS .bch resolver" | ForEach-Object {
Remove-DnsClientNrptRule -Name $_.Name -Force
Write-Host "[ok] NRPT rule removed"
}
if (Test-Path $stateFile) {
$state = Get-Content $stateFile | ConvertFrom-Json
foreach ($a in $state.adapters) {
# Never "restore" DNS to 127.0.0.1 (the daemon we're removing) — that leaves the
# machine with no working resolver. Drop loopback; if nothing real remains, or the
# install marked this adapter, hand DNS back to DHCP (automatic).
$servers = @($a.servers | Where-Object { $_ -and $_ -ne '127.0.0.1' -and $_ -ne '::1' })
if ($a.resetToDhcp -or $servers.Count -eq 0) {
Set-DnsClientServerAddress -InterfaceAlias $a.alias -ResetServerAddresses -ErrorAction SilentlyContinue
Write-Host "[ok] adapter '$($a.alias)' DNS reset to automatic (DHCP)"
} else {
Set-DnsClientServerAddress -InterfaceAlias $a.alias -ServerAddresses $servers -ErrorAction SilentlyContinue
Write-Host "[ok] adapter '$($a.alias)' DNS restored to: $($servers -join ', ')"
}
}
Remove-Item $stateFile
}
# SAFETY SWEEP — always, even with a missing/stale state file. No adapter may be
# left pointing at 127.0.0.1 (or ::1) once the daemon is gone, or the machine loses
# ALL DNS ("server not responding"). This is the guarantee that a botched or
# state-less uninstall can never take the user offline.
foreach ($if in Get-DnsClientServerAddress -AddressFamily IPv4 |
Where-Object { $_.InterfaceAlias -notmatch 'Loopback' -and ($_.ServerAddresses -contains '127.0.0.1') }) {
Set-DnsClientServerAddress -InterfaceAlias $if.InterfaceAlias -ResetServerAddresses -ErrorAction SilentlyContinue
Write-Host "[fix] adapter '$($if.InterfaceAlias)' was still pointed at the resolver — reset to automatic (DHCP)"
}
# Remove the BNS root CA robustly — by SUBJECT, not only the thumbprint recorded
# in install-state.json. A missing or stale state file (the daemon was run by
# hand, or this is an upgrade from an older build) must never leave an orphaned
# trusted root in the machine store.
$removed = 0
Get-ChildItem Cert:\LocalMachine\Root | Where-Object { $_.Subject -match "BNS Local Root CA" } | ForEach-Object {
Remove-Item $_.PSPath -Force
Write-Host "[ok] BNS Local Root CA removed ($($_.Thumbprint))"
$removed++
}
if ($removed -eq 0) { Write-Host "[ok] no BNS root CA left in the machine store" }
try {
Remove-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" -Recurse -Force -ErrorAction Stop
Write-Host "[ok] Firefox certificate policies removed (enterprise-roots + install)"
} catch {}
try {
Remove-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Recurse -Force -ErrorAction Stop
Write-Host "[ok] Firefox DNS-over-HTTPS policy removed (DoH restored to default)"
} catch {}
foreach ($task in "BNS Resolver Daemon", "BNS Sia Bridge") {
try { Stop-ScheduledTask -TaskName $task -ErrorAction Stop } catch {}
try {
Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction Stop
Write-Host "[ok] scheduled task '$task' removed"
} catch {}
}
Clear-DnsClientCache
Write-Host "[done] system DNS configuration restored"

34
sia-test.html Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>siatest.bch — stored on Sia, named on Bitcoin Cash</title>
<style>
body { margin:0; min-height:100vh; display:grid; place-items:center;
background:#0a0d14; color:#e7eaf1; font:16px/1.6 system-ui,sans-serif; }
main { max-width:34rem; padding:2rem; text-align:center; }
h1 { color:#d6ff3d; font-size:1.6rem; }
.stack { text-align:left; background:#121724; border:1px solid rgba(255,255,255,.08);
border-radius:12px; padding:1rem 1.4rem; margin-top:1.5rem; }
.stack div { padding:.35rem 0; border-bottom:1px solid rgba(255,255,255,.06); }
.stack div:last-child { border-bottom:0; }
.ok { color:#4fd1a5; }
</style>
</head>
<body>
<main>
<h1>This page has no server.</h1>
<p>You are reading a file stored on the <b>Sia decentralized host network</b> —
erasure-coded into 30 encrypted pieces on independent hosts worldwide —
reached via a domain name that is an <b>NFT on the Bitcoin Cash blockchain</b>.</p>
<div class="stack">
<div><span class="ok">✔</span> Name: CashTokens NFT on BCH chipnet</div>
<div><span class="ok">✔</span> Resolution: local BNS daemon reading the chain</div>
<div><span class="ok">✔</span> Storage: Sia host network (via Sia Storage)</div>
<div><span class="ok">✔</span> TLS: BNS Local Root CA, fingerprint on-chain</div>
<div><span class="ok">✔</span> Registrar, host, CA involved: none</div>
</div>
</main>
</body>
</html>

7
src/config.js Normal file
View file

@ -0,0 +1,7 @@
export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
export const PREFIX = "BNS1";
export const TLD = ".bch";
export const BEACON_DUST_SAT = 600;
export const NAME_RE = /^[a-z0-9-]{1,32}$/;
export const WALLET_FILE = new URL("../wallets.json", import.meta.url);
export const APP_PORT = 4626;

266
src/daemon/bnsd.js Normal file
View file

@ -0,0 +1,266 @@
// bnsd — system-wide BNS/BCNR resolver daemon (secret-free, multi-TLD).
//
// Two servers in one process:
// - DNS on udp/53: for every query it checks the BCNR chain index FIRST; if the
// name is registered (any TLD) it answers with the name's "ip" record, else
// 127.0.0.1 so the local gateway serves it. Every unregistered name is
// forwarded to an upstream resolver — i.e. BCNR-first, ICANN-fallback. That
// makes it safe to be the machine's system DNS.
// - HTTP/HTTPS gateway: routed by Host header; serves h / s3 / p / u records.
// Sia (s3) content is fetched from the PUBLIC gateway (navigate.st) — no S3
// credentials ship. HTTPS is terminated with certs minted on the fly by the
// BNS local CA (generated on THIS machine at install; the root key never ships).
//
// Zero secrets by design: uses the wallet-free resolver-web.js reader (electrum
// over WebSocket) — no wallet, no mainnet-js, no S3 keys, no sia-s3.json.
//
// Usage: node bnsd.js [--dns-port 53] [--http-port 80] [--https-port 443]
// [--upstream 1.1.1.1] [--bind 127.0.0.1] [--dns-bind 127.0.0.1]
//
// --bind defaults to loopback. A PUBLIC gateway must pass --bind 0.0.0.0
// explicitly; --dns-bind is deliberately separate so that doing so does not also
// expose the DNS server (an open resolver is a DDoS amplification relay).
import dgram from "dgram";
import https from "node:https";
import tls from "node:tls";
import { Readable } from "node:stream";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import express from "express";
import WebSocket from "ws";
import { buildIndex, normalizeName } from "../lib/resolver-web.js";
import { issueCert } from "../lib/ca.js";
import { isPermittedHost } from "../lib/tlds.js";
// ---------- configuration ----------
// Effective value = CLI flag, else config file, else built-in default. So the
// installed program is fully file-configurable and a settings UI just edits
// ariadne.config.json (then restarts the service to apply).
const CONFIG_FILE = fileURLToPath(new URL("../../ariadne.config.json", import.meta.url));
let cfg = {};
try {
if (existsSync(CONFIG_FILE)) { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); console.log(`bnsd config: loaded ${CONFIG_FILE}`); }
} catch (e) { console.error(`bnsd config: ${CONFIG_FILE} unreadable (${e.message}) — using defaults`); }
const arg = (name) => { const i = process.argv.indexOf("--" + name); return i > -1 ? process.argv[i + 1] : undefined; };
const opt = (cli, key, dflt) => arg(cli) ?? cfg[key] ?? dflt;
const DNS_PORT = Number(opt("dns-port", "dnsPort", 53));
const HTTP_PORT = Number(opt("http-port", "httpPort", 80));
const HTTPS_PORT = Number(opt("https-port", "httpsPort", 443));
const UPSTREAM = opt("upstream", "upstream", "1.1.1.1");
// Listen addresses. Both default to loopback: on an end-user machine this daemon
// is a local resolver, and binding it to every interface exposed it to the whole
// LAN. A public gateway (navigate.st) must opt in explicitly with --bind 0.0.0.0.
const BIND = opt("bind", "bind", "127.0.0.1");
// DNS is bound separately and stays on loopback unless deliberately overridden:
// a recursive resolver reachable from the internet is an amplification relay for
// DDoS traffic, so --bind alone must never open it.
const DNS_BIND = opt("dns-bind", "dnsBind", "127.0.0.1");
const GATEWAY = cfg.gateway ?? "https://navigate.st"; // public content relay (secret-free)
const TTL = 60;
// Canonical BCNR index key ("<label>.<tld>") for a hostname, or null if the name
// isn't a valid BNS name (in which case it's not ours — forward to ICANN).
const bcnrKey = (host) => { try { return normalizeName(host); } catch { return null; } };
// ---------- chain index (BCNR), refreshed in background, last good copy kept ----------
let index = new Map();
async function refresh() {
try { index = await buildIndex({ WebSocket }); }
catch (e) { console.error("index refresh failed (keeping last good):", e.message); }
}
await refresh();
setInterval(refresh, 30_000);
// ---------- minimal DNS wire helpers ----------
function parseQuestion(msg) {
let i = 12;
const labels = [];
while (i < msg.length) {
const len = msg[i];
if (len === 0) { i += 1; break; }
if (len > 63) return null;
labels.push(msg.subarray(i + 1, i + 1 + len).toString("ascii"));
i += 1 + len;
}
if (i + 4 > msg.length) return null;
const qtype = msg.readUInt16BE(i);
return { name: labels.join(".").toLowerCase(), qtype, qend: i + 4 };
}
function reply(query, qend, { rcode = 0, answers = [] }) {
const question = query.subarray(12, qend);
const header = Buffer.alloc(12);
query.copy(header, 0, 0, 2);
header.writeUInt16BE(0x8580 | rcode, 2);
header.writeUInt16BE(1, 4);
header.writeUInt16BE(answers.length, 6);
const parts = [header, question];
for (const ip of answers) {
const rr = Buffer.alloc(16);
rr.writeUInt16BE(0xc00c, 0);
rr.writeUInt16BE(1, 2);
rr.writeUInt16BE(1, 4);
rr.writeUInt32BE(TTL, 6);
rr.writeUInt16BE(4, 10);
const o = ip.split(".").map(Number);
for (let j = 0; j < 4; j++) rr[12 + j] = o[j];
parts.push(rr);
}
return Buffer.concat(parts);
}
function forward(msg, rinfo, server) {
const sock = dgram.createSocket("udp4");
const timer = setTimeout(() => sock.close(), 3000);
sock.on("message", (resp) => {
clearTimeout(timer);
server.send(resp, rinfo.port, rinfo.address);
sock.close();
});
sock.on("error", () => { clearTimeout(timer); try { sock.close(); } catch {} });
sock.send(msg, 53, UPSTREAM);
}
// A busy port disables just that server, not the whole daemon.
function warnPort(label, port, e) {
if (e.code === "EADDRINUSE")
console.error(`bnsd: ${label} port ${port} is already in use — ${label} stays disabled (other servers keep running). Stop whatever holds it, or pass --${label.toLowerCase()}-port.`);
else console.error(`bnsd: ${label} :${port} error:`, e.message);
}
// ---------- DNS server: BCNR-first, ICANN-fallback ----------
const dns = dgram.createSocket("udp4");
dns.on("error", (e) => warnPort("DNS", DNS_PORT, e));
dns.on("message", (msg, rinfo) => {
const q = parseQuestion(msg);
if (!q) return;
const key = bcnrKey(q.name);
const entry = key ? index.get(key) : null;
if (!entry) return forward(msg, rinfo, dns); // not on BCNR -> ICANN
if (q.qtype === 1) { // A
const ip = entry.records.ip ?? "127.0.0.1"; // no ip -> local gateway serves it
return dns.send(reply(msg, q.qend, { answers: [ip] }), rinfo.port, rinfo.address);
}
dns.send(reply(msg, q.qend, {}), rinfo.port, rinfo.address); // known name, other qtype
});
dns.bind(DNS_PORT, DNS_BIND, () => console.log(`bnsd DNS: ${DNS_BIND}:${DNS_PORT} (BCNR-first, upstream ${UPSTREAM})`));
// ---------- HTTP gateway ----------
// "p" record: reverse-proxy the upstream site under the BNS host (address bar stays on the name).
async function proxy(req, res, upstreamUrl) {
try {
const upstream = new URL(upstreamUrl);
const target = new URL(req.originalUrl, upstream.origin);
const resp = await fetch(target, {
method: req.method,
headers: {
"user-agent": req.headers["user-agent"] ?? "bnsd",
accept: req.headers.accept ?? "*/*",
"accept-language": req.headers["accept-language"] ?? "en",
"content-type": req.headers["content-type"] ?? undefined,
},
redirect: "manual",
body: ["GET", "HEAD"].includes(req.method) ? undefined : req,
duplex: "half",
});
const headers = {};
for (const [k, v] of resp.headers) {
if (["content-encoding", "content-length", "transfer-encoding", "connection"].includes(k)) continue;
headers[k] = v;
}
if (headers.location?.startsWith(upstream.origin)) {
headers.location = headers.location.slice(upstream.origin.length) || "/";
}
res.status(resp.status).set(headers);
if (resp.body) Readable.fromWeb(resp.body).pipe(res);
else res.end();
} catch (e) {
res.status(502).send("bnsd proxy error: " + e.message);
}
}
// "s3" record: fetch Sia content from the PUBLIC gateway (holds the keys + owns
// the subfolder mapping) — no credentials in this build.
async function serveSia(req, res, host) {
try {
// Directory URLs (`/`, `/apps/`) need an explicit `index.html` — the
// s3-backed Sia store has no dir listing and returns NoSuchKey without it.
const url = req.originalUrl.endsWith("/") ? req.originalUrl + "index.html" : req.originalUrl;
const r = await fetch(`${GATEWAY}/bns/${host}${url}`);
const ct = r.headers.get("content-type") || "application/octet-stream";
const buf = Buffer.from(await r.arrayBuffer());
let body = buf;
if (ct.includes("text/html")) {
// strip the gateway's path-form <base> so assets resolve on the BNS origin
body = Buffer.from(buf.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
}
res.status(r.status).set("content-type", ct).send(body);
} catch (e) {
res.status(502).send("bnsd sia error: " + e.message);
}
}
const app = express();
app.use((req, res) => {
const host = (req.headers.host ?? "").split(":")[0].toLowerCase();
const key = bcnrKey(host);
const entry = key ? index.get(key) : null;
if (!entry) return res.status(404).send(`bnsd: ${host} is not registered on BCNR`);
if (entry.records.s3) return serveSia(req, res, host);
if (entry.records.p) return proxy(req, res, entry.records.p);
if (entry.records.h) return res.type("html").send(entry.records.h);
if (entry.records.u) return res.redirect(entry.records.u);
res.json(entry);
});
const httpServer = app.listen(HTTP_PORT, BIND, () => console.log(`bnsd gateway: ${BIND}:${HTTP_PORT}`));
httpServer.on("error", (e) => warnPort("HTTP", HTTP_PORT, e));
// ---------- HTTPS gateway ----------
// Terminates TLS for BNS names with certs minted on the fly, signed by the BNS
// local root CA (generated on this machine at install and placed in the trust
// store). Only names the DNS routed here are ever presented a cert.
// Minting is gated and the cache is bounded, because SNICallback runs on
// unauthenticated input: the client picks the name. Issuing for any name asked
// for would let a caller drive unbounded RSA keygen and unbounded Map growth
// just by varying the SNI field. A name gets a certificate only if it is under
// a permitted TLD *and* actually registered on chain — a finite set we already
// hold in memory.
const CERT_CACHE_MAX = 512;
const certCache = new Map();
function mayIssue(host) {
if (!isPermittedHost(host)) return false; // outside the CA's name constraints
const key = bcnrKey(host);
return !!(key && index.has(key)); // unregistered names get no certificate
}
function contextFor(host) {
const hit = certCache.get(host);
if (hit) { certCache.delete(host); certCache.set(host, hit); return hit; } // LRU touch
const { keyPem, certPem } = issueCert(host);
const ctx = tls.createSecureContext({ key: keyPem, cert: certPem });
certCache.set(host, ctx);
if (certCache.size > CERT_CACHE_MAX) certCache.delete(certCache.keys().next().value);
return ctx;
}
const fallback = issueCert("gateway.bcnr");
const httpsServer = https.createServer(
{
key: fallback.keyPem,
cert: fallback.certPem,
SNICallback: (servername, cb) => {
const host = String(servername ?? "").toLowerCase().replace(/\.$/, "");
if (!host) return cb(null, undefined); // no SNI -> default context
if (!mayIssue(host)) {
// Fail the handshake rather than mint. The client sees a TLS error,
// which is the honest answer: we hold no certificate for that name.
return cb(new Error(`bnsd: no certificate for "${host}" (not a registered BNS name)`));
}
try { cb(null, contextFor(host)); }
catch (e) { cb(e); }
},
},
app,
);
httpsServer.on("error", (e) => warnPort("HTTPS", HTTPS_PORT, e));
httpsServer.listen(HTTPS_PORT, BIND, () => console.log(`bnsd https gateway: ${BIND}:${HTTPS_PORT}`));

24
src/daemon/dig.js Normal file
View file

@ -0,0 +1,24 @@
// Tiny dig: node src/daemon/dig.js <name> [port]
import dgram from "dgram";
const [name, port = "5354"] = process.argv.slice(2);
const labels = name.split(".").flatMap((l) => [Buffer.from([l.length]), Buffer.from(l, "ascii")]);
const query = Buffer.concat([
Buffer.from([0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]),
...labels, Buffer.from([0, 0, 1, 0, 1]),
]);
const sock = dgram.createSocket("udp4");
const timer = setTimeout(() => { console.log("TIMEOUT: no response"); sock.close(); }, 4000);
sock.on("message", (msg) => {
clearTimeout(timer);
const rcode = msg[3] & 0x0f;
const ancount = msg.readUInt16BE(6);
console.log("rcode:", rcode === 0 ? "NOERROR" : rcode === 3 ? "NXDOMAIN" : rcode, "answers:", ancount);
if (ancount > 0) {
// last 4 bytes of first A answer
const ip = [...msg.subarray(msg.length - 4)].join(".");
console.log("A:", ip);
}
console.log("raw:", msg.toString("hex"));
sock.close();
});
sock.send(query, Number(port), "127.0.0.1");

View file

@ -0,0 +1,157 @@
// BNS public gateway (the "remote relay", convenience tier).
// Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves
// the content over plain HTTPS so ANY device's browser can view .bch sites with
// no install. Two URL forms:
// path form: https://navigate.st/bns/<name>.bch/... (<base> injected)
// subdomain form: https://<name-flattened>.bch.navigate.st/ (own origin, no <base>)
// where dots in a multi-level name become dashes: coinspectrum.deviant.bch
// -> coinspectrum-deviant.bch.navigate.st
//
// Honest disclosure (convenience tier, not the trustless one): the operator can
// see which .bch sites you request. No logs — but that's trust. Install the
// resolver or Theseus for trustless resolution; use Tor for IP privacy.
import http from "http";
import { AwsClient } from "aws4fetch";
import WebSocket from "ws";
import { resolveName, buildIndex, normalizeName } from "./resolver-web.js";
// Full-index cache (rebuilt every 30s) for fast availability lookups.
let _idx = { at: 0, index: null };
async function getIndex() {
if (!_idx.index || Date.now() - _idx.at > 30000) {
_idx = { at: Date.now(), index: await buildIndex({ WebSocket }) };
}
return _idx.index;
}
const MOUNT = process.env.BNS_MOUNT || "/bns";
const PORT = Number(process.env.BNS_PORT || 8700);
const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500";
const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix
const aws = new AwsClient({
accessKeyId: process.env.AK, secretAccessKey: process.env.SK,
service: "s3", region: "us-east-1",
});
const cache = new Map();
async function resolve(host) {
const c = cache.get(host);
if (c && Date.now() - c.at < 30000) return c.entry;
const entry = await resolveName(host, { WebSocket }); // resolveName normalizes any <sub>.<label>.<tld>
cache.set(host, { entry, at: Date.now() });
return entry;
}
// subfolder = labels before the registered "<label>.<tld>" (drop tld, drop label)
const subFolder = (host) => {
const l = host.split("."); l.pop(); l.pop(); return l.reverse().join("/");
};
const MIME = { html: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", webp: "image/webp",
woff2: "font/woff2", woff: "font/woff", wasm: "application/wasm", txt: "text/plain" };
const guess = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream";
function injectBase(html, href) {
if (!href || /<base\s/i.test(html)) return html;
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + `<base href="${href}">`);
return `<base href="${href}">` + html;
}
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
// Serve a resolved name. baseHref = null for subdomain form (own origin).
async function serve(res, name, reqPath, baseHref) {
let entry;
try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); }
if (!entry) { res.writeHead(404, { "content-type": "text/html" }); return res.end(nx(name)); }
const r = entry.records;
try {
if (r.h) {
if (reqPath !== "/") { res.writeHead(404); return res.end("not found"); }
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end(injectBase(r.h, baseHref));
}
if (r.s3) {
const sub = subFolder(name);
let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/";
const key = base.endsWith("/") ? base + (reqPath === "/" ? "index.html" : reqPath.slice(1)) : base;
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
if (r.ip) {
const up = await fetch(`http://${r.ip}${reqPath}`, { headers: { host: name } });
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type") || guess(reqPath);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
if (r.u) { res.writeHead(302, { location: r.u }); return res.end(); }
res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(entry, null, 2));
} catch (e) { res.writeHead(502); res.end("gateway error: " + e.message); }
}
const server = http.createServer(async (req, res) => {
try {
const hostHeader = (req.headers.host || "").split(":")[0].toLowerCase();
// --- registrar API: availability of a name ---
// GET /api/name/<name> -> { name, tld, registered, records? }
if (req.url.startsWith("/api/name/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
const raw = decodeURIComponent(req.url.slice("/api/name/".length).split("?")[0]);
try {
const key = normalizeName(raw); // "<label>.<tld>"
const tld = key.split(".").pop();
const entry = (await getIndex()).get(key);
res.writeHead(200);
res.end(JSON.stringify({
name: key, tld, registered: !!entry,
records: entry?.records ?? null, category: entry?.category ?? null,
}));
} catch (e) {
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
}
return;
}
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
if (hostHeader.endsWith("." + SUB_BASE)) {
const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // "<flattened>.bch" or "<flattened>"
const flat = inner.endsWith(".bch") ? inner.slice(0, -4) : inner;
if (flat) {
const name = flat.replace(/-/g, ".") + ".bch"; // dashes back to dots
return serve(res, name, req.url || "/", null); // own origin: no <base>
}
}
// bare apex of SUB_BASE (e.g. bch.navigate.st) -> home
if (hostHeader === SUB_BASE) { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
// --- path form: /bns/<name>.bch/... ---
let p = req.url;
if (p === MOUNT || p === MOUNT + "/") { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
if (!p.startsWith(MOUNT + "/")) { res.writeHead(404); return res.end("bns gateway"); }
p = p.slice(MOUNT.length + 1);
const m = p.match(/^([a-z0-9-]+(?:\.[a-z0-9-]+)+)(\/.*)?$/i); // <name>.<tld>, any TLD
if (!m) { res.writeHead(400, { "content-type": "text/html" }); return res.end(`usage: ${MOUNT}/<name>.<tld>/`); }
const name = m[1].toLowerCase();
const rest = m[2] || "/";
return serve(res, name, rest, `${MOUNT}/${name}/`);
} catch (e) { res.writeHead(500); res.end("gateway error: " + e.message); }
});
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT}, subdomain *.${SUB_BASE})`));
function home() {
return `<!doctype html><meta charset=utf-8><title>BNS Gateway</title>
<body style="font:16px system-ui;max-width:640px;margin:3rem auto;padding:0 1rem;color:#e7eaf1;background:#0f1420">
<h1 style="color:#d6ff3d">⛓ BNS Gateway</h1>
<p>View Bitcoin Cash <b>.bch</b> names in any browser — no install.</p>
<ul>
<li><a href="/bns/hello.bch">/bns/hello.bch</a> · or <a href="https://hello.${SUB_BASE}/">hello.${SUB_BASE}</a></li>
<li><a href="/bns/coinspectrum.deviant.bch">/bns/coinspectrum.deviant.bch</a></li>
</ul>
<p style="color:#8b98a9;font-size:13px">This gateway resolves names for you, so it can see which .bch
sites you request (it keeps no logs — you're trusting that). For trustless resolution install the local
resolver/extension; for IP privacy use Tor. By Silent Mode (a Deviant project).</p></body>`;
}

201
src/lib/bns.js Normal file
View file

@ -0,0 +1,201 @@
// BNS1 core: payload codec, chain indexer/resolver, and tx builders.
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
import { BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js";
// ---------- payload codec ----------
const LABEL_RE = /^[a-z0-9-]{1,32}$/;
const TLD_RE = /^[a-z0-9-]{1,16}$/;
const RAWNAME_RE = /^[a-z0-9-]{1,32}(\.[a-z0-9-]{1,16})?$/;
// On-chain name for a registration: bare "<label>" for .bch (backward compat),
// or "<label>.<tld>" for any other TLD. The rightmost label is the registry.
export function normalizeName(input) {
const parts = String(input).toLowerCase().trim().replace(/\.$/, "").split(".").filter(Boolean);
let label, tld;
if (parts.length === 1) { label = parts[0]; tld = "bch"; }
else { tld = parts[parts.length - 1]; label = parts[parts.length - 2]; }
if (!LABEL_RE.test(label) || !TLD_RE.test(tld)) {
throw new Error(`invalid name "${input}" (allowed: <label>.<tld>, [a-z0-9-])`);
}
return tld === "bch" ? label : `${label}.${tld}`;
}
export function encodePayload(op, name, records) {
const json = JSON.stringify({ o: op, n: name, r: records });
const buf = Buffer.from(PREFIX + json, "utf8");
if (buf.length > 200) throw new Error(`records too large (${buf.length} > 200 bytes)`);
return buf;
}
// Parse a raw output script hex; return payload object or null.
export function parseOpReturn(scriptHex) {
if (!scriptHex?.startsWith("6a")) return null; // OP_RETURN
let i = 2;
const pushes = [];
while (i < scriptHex.length) {
let op = parseInt(scriptHex.slice(i, i + 2), 16);
i += 2;
let len;
if (op <= 0x4b) len = op;
else if (op === 0x4c) { len = parseInt(scriptHex.slice(i, i + 2), 16); i += 2; }
else if (op === 0x4d) { len = parseInt(scriptHex.slice(i + 2, i + 4) + scriptHex.slice(i, i + 2), 16); i += 4; }
else return null;
pushes.push(Buffer.from(scriptHex.slice(i, i + len * 2), "hex"));
i += len * 2;
}
const data = pushes[0]?.toString("utf8") ?? "";
if (!data.startsWith(PREFIX)) return null;
try {
const p = JSON.parse(data.slice(PREFIX.length));
if ((p.o === "REG" || p.o === "UPD") && typeof p.n === "string") return p;
} catch { /* not ours */ }
return null;
}
// ---------- indexer / resolver ----------
// (height, txid) order; unconfirmed (height<=0) last.
function byHeightThenTxid(a, b) {
const ha = a.height > 0 ? a.height : Infinity;
const hb = b.height > 0 ? b.height : Infinity;
return ha - hb || a.tx_hash.localeCompare(b.tx_hash);
}
// Never index a transaction before one it spends from — otherwise an UPD that
// happens to sort below its own REG is evaluated against an index that does not
// yet contain the name, and is dropped. Keeps the txid tiebreak for independent
// transactions. Mirrors resolver-web.js `orderByDependency` — change both.
function orderByDependency(history, txs) {
const inSet = new Set(history.map((h) => h.tx_hash));
const deps = new Map(
history.map((h) => {
const spent = new Set();
for (const vin of txs.get(h.tx_hash)?.vin ?? []) {
if (vin.txid && vin.txid !== h.tx_hash && inSet.has(vin.txid)) spent.add(vin.txid);
}
return [h.tx_hash, spent];
}),
);
const emitted = new Set();
const ordered = [];
const pending = [...history];
while (pending.length) {
let i = pending.findIndex((h) => [...deps.get(h.tx_hash)].every((d) => emitted.has(d)));
if (i < 0) i = 0; // unreachable on a real chain; guarantees termination
const [next] = pending.splice(i, 1);
emitted.add(next.tx_hash);
ordered.push(next);
}
return ordered;
}
// Builds { name -> { name, category, records, txid, height, updatedTxid } }
export async function buildIndex(provider) {
const history = await provider.getHistory(BEACON);
history.sort(byHeightThenTxid);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); }
catch { /* unreadable tx — skip it entirely */ }
}
const names = new Map();
const byCategory = new Map();
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
const tx = txs.get(tx_hash);
let payload = null;
for (const vout of tx.vout) {
payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
}
if (!payload) continue;
const name = payload.n;
if (!RAWNAME_RE.test(name)) continue;
if (payload.o === "REG") {
if (names.has(name)) continue; // first valid REG wins
// the same tx must mint a certificate whose commitment is the name
const commitment = Buffer.from(name, "utf8").toString("hex");
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
if (!mint) continue;
const entry = {
name, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,
};
names.set(name, entry);
byCategory.set(entry.category, entry);
} else if (payload.o === "UPD") {
const entry = names.get(name);
if (!entry) continue;
// ownership proof: some output carries the name's category
// (consensus: impossible unless an input held that category)
const proves = tx.vout.some((v) => v.tokenData?.category === entry.category);
if (!proves) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
}
}
return names;
}
export async function resolveName(provider, input) {
const name = normalizeName(input);
const index = await buildIndex(provider);
return index.get(name) ?? null;
}
// ---------- tx builders ----------
// CashTokens genesis must spend an outpoint with index 0; self-send to make one if needed.
async function ensureGenesisInput(wallet) {
const utxos = await wallet.getUtxos();
if (utxos.some((u) => u.vout === 0 && !u.token)) return;
await wallet.send([{ cashaddr: wallet.cashaddr, value: 10000n, unit: "sat" }]);
}
export async function registerName(wallet, input, records) {
const name = normalizeName(input);
const index = await buildIndex(wallet.provider);
if (index.has(name)) throw new Error(`"${name}${'.bch'}" is already registered`);
await ensureGenesisInput(wallet);
const payload = encodePayload("REG", name, records);
const result = await wallet.tokenGenesis(
{
cashaddr: wallet.tokenaddr,
nft: {
capability: NFTCapability.none,
commitment: Buffer.from(name, "utf8").toString("hex"),
},
value: 1000n,
},
[
OpReturnData.fromUint8Array(payload),
{ cashaddr: BEACON, value: BEACON_DUST_SAT, unit: "sat" },
],
);
return { name, txid: result.txId, category: result.tokenIds?.[0] };
}
export async function updateName(wallet, input, records) {
const name = normalizeName(input);
const index = await buildIndex(wallet.provider);
const entry = index.get(name);
if (!entry) throw new Error(`"${name}.bch" is not registered`);
const payload = encodePayload("UPD", name, records);
const result = await wallet.send([
new TokenSendRequest({
cashaddr: wallet.tokenaddr,
category: entry.category,
nft: {
capability: NFTCapability.none,
commitment: Buffer.from(name, "utf8").toString("hex"),
},
}),
OpReturnData.fromUint8Array(payload),
{ cashaddr: BEACON, value: BEACON_DUST_SAT, unit: "sat" },
]);
return { name, txid: result.txId, category: entry.category };
}

194
src/lib/ca.js Normal file
View file

@ -0,0 +1,194 @@
// BNS local certificate authority.
// The chain binds name -> owner; this CA binds name -> TLS key on machines that
// install its root ("make our own https").
//
// The root is NAME-CONSTRAINED (RFC 5280 §4.2.1.10) to the TLDs in tlds.js and
// excluded from all IP addresses. That matters because install.ps1 puts this
// root in the machine trust store: an unconstrained root would mean whoever can
// read ca/root-ca.key could mint a trusted certificate for *any* domain — the
// user's bank included. With constraints, the worst case is limited to names
// this project already governs.
//
// ca/root-ca.key is still a secret worth protecting (it can forge any name under
// the permitted TLDs), it is generated per machine, and it must never ship.
import forge from "node-forge";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { fileURLToPath } from "url";
import { PERMITTED_TLDS, isPermittedHost } from "./tlds.js";
const CA_DIR = fileURLToPath(new URL("../../ca/", import.meta.url));
const KEY_FILE = CA_DIR + "root-ca.key";
const CERT_FILE = CA_DIR + "root-ca.crt";
const NAME_CONSTRAINTS_OID = "2.5.29.30";
function makeCert(publicKey, subjectAttrs, issuerAttrs, extensions, years) {
const cert = forge.pki.createCertificate();
cert.publicKey = publicKey;
cert.serialNumber = "00" + forge.util.bytesToHex(forge.random.getBytesSync(8));
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + years);
cert.setSubject(subjectAttrs);
cert.setIssuer(issuerAttrs);
cert.setExtensions(extensions);
return cert;
}
// NameConstraints ::= SEQUENCE {
// permittedSubtrees [0] GeneralSubtrees OPTIONAL,
// excludedSubtrees [1] GeneralSubtrees OPTIONAL }
// GeneralSubtree ::= SEQUENCE { base GeneralName, ... }
// GeneralName: dNSName is [2] IA5String, iPAddress is [7] OCTET STRING.
//
// node-forge has no nameConstraints support, so this is hand-built ASN.1 handed
// to forge as a raw extension.
function nameConstraintsExt(tlds) {
const { asn1 } = forge;
const CS = asn1.Class.CONTEXT_SPECIFIC;
const seq = (v) => asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, v);
const dnsSubtree = (tld) => seq([asn1.create(CS, 2, false, tld)]);
// In a constraint an iPAddress base is address||mask. All-zero address with an
// all-zero mask is "every address", so excluding it bars every IP-address SAN
// for both IPv4 (4+4 bytes) and IPv6 (16+16).
const ipSubtree = (len) => seq([asn1.create(CS, 7, false, "\0".repeat(len))]);
const nc = seq([
asn1.create(CS, 0, true, tlds.map(dnsSubtree)),
asn1.create(CS, 1, true, [ipSubtree(8), ipSubtree(32)]),
]);
// RFC 5280: "Conforming CAs MUST mark this extension as critical."
return { id: NAME_CONSTRAINTS_OID, critical: true, value: asn1.toDer(nc).getBytes() };
}
// Pull the permitted dNSName bases back out of an existing root, so we can tell
// whether it still matches the configured TLD list. Returns null if the cert
// carries no name-constraints extension at all (i.e. a pre-constraint root).
function readPermittedDns(cert) {
const ext = (cert.extensions ?? []).find((e) => e.id === NAME_CONSTRAINTS_OID);
if (!ext) return null;
try {
const der = typeof ext.value === "string" ? ext.value : forge.asn1.toDer(ext.value).getBytes();
const nc = forge.asn1.fromDer(der);
const permitted = nc.value.find((v) => v.type === 0 && v.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC);
if (!permitted) return [];
return permitted.value
.map((sub) => sub.value?.[0])
.filter((gn) => gn && gn.type === 2)
.map((gn) => gn.value);
} catch {
return [];
}
}
function sameSet(a, b) {
return a.length === b.length && [...a].sort().join() === [...b].sort().join();
}
export function loadOrCreateRootCA() {
mkdirSync(CA_DIR, { recursive: true });
if (existsSync(KEY_FILE) && existsSync(CERT_FILE)) {
const cert = forge.pki.certificateFromPem(readFileSync(CERT_FILE, "utf8"));
const permitted = readPermittedDns(cert);
if (permitted === null) {
console.error(
"\n*** SECURITY: ca/root-ca.crt has NO name constraints ***\n" +
" This root was generated before constraints were added. While it is in the\n" +
" machine trust store, anyone who can read ca/root-ca.key can mint a trusted\n" +
" certificate for ANY domain on this machine, not just BNS names.\n" +
" Rotate it: setup\\uninstall.ps1 -> delete ca\\ -> setup\\install.ps1\n",
);
} else if (!sameSet(permitted, PERMITTED_TLDS)) {
console.error(
`\n*** ca/root-ca.crt is constrained to [${permitted.join(", ")}] but the configured ` +
`TLD list is [${PERMITTED_TLDS.join(", ")}].\n` +
" Certificates for the newly-configured TLDs will be REJECTED by clients until the\n" +
" root is regenerated and re-imported (uninstall -> delete ca\\ -> install).\n",
);
}
return { key: forge.pki.privateKeyFromPem(readFileSync(KEY_FILE, "utf8")), cert };
}
const keys = forge.pki.rsa.generateKeyPair(2048);
const attrs = [
{ name: "commonName", value: "BNS Local Root CA" },
{ name: "organizationName", value: "BNS (.bch on Bitcoin Cash)" },
];
const cert = makeCert(keys.publicKey, attrs, attrs, [
{ name: "basicConstraints", cA: true, critical: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
// Distinguishes THIS root from any prior "BNS Local Root CA" that a rotate
// left orphaned in Firefox's NSS store. Without an SKI, Firefox's chain
// builder picks a candidate root by Subject DN alone — and every regenerated
// root has the same DN — so it can pick the wrong pubkey and reject valid
// leaves with SEC_ERROR_BAD_SIGNATURE.
{ name: "subjectKeyIdentifier" },
nameConstraintsExt(PERMITTED_TLDS),
], 10);
cert.sign(keys.privateKey, forge.md.sha256.create());
writeFileSync(KEY_FILE, forge.pki.privateKeyToPem(keys.privateKey));
writeFileSync(CERT_FILE, forge.pki.certificateToPem(cert));
console.error(`created new BNS Local Root CA in ca/ — constrained to: ${PERMITTED_TLDS.join(", ")}`);
return { key: keys.privateKey, cert };
}
// One leaf keypair per process, reused for every name this daemon serves.
// Generating RSA-2048 per hostname is what made on-the-fly issuance a denial-of-
// service vector (see bnsd.js SNI handling). All leaves are served by this same
// process anyway, so a shared key concedes nothing that was separated before.
let leafKeys = null;
function leafKeyPair() {
if (!leafKeys) leafKeys = forge.pki.rsa.generateKeyPair(2048);
return leafKeys;
}
// Issue a leaf certificate for a BNS hostname, signed by the local root.
// Throws for hostnames outside the permitted TLDs: the root's own constraints
// would make such a certificate useless anyway, and refusing here keeps callers
// from burning work on names that can never validate.
export function issueCert(hostname) {
if (!isPermittedHost(hostname)) {
throw new Error(
`refusing to issue a certificate for "${hostname}" — not under a permitted TLD ` +
`(${PERMITTED_TLDS.join(", ")}). See src/lib/tlds.js.`,
);
}
const ca = loadOrCreateRootCA();
const keys = leafKeyPair();
// Derive the CA's SubjectKeyIdentifier so the leaf can point at *this
// specific* root, not any other "CN=BNS Local Root CA" a prior install
// may have left orphaned in Firefox's NSS store. Uses node-forge's own
// generator so this matches the value written into the root's SKI
// extension byte-for-byte (RFC 3280 §4.2.1.2 method 1 over RSAPublicKey).
const caSki = ca.cert.generateSubjectKeyIdentifier().getBytes();
const cert = makeCert(
keys.publicKey,
[{ name: "commonName", value: hostname }],
ca.cert.subject.attributes,
[
{ name: "basicConstraints", cA: false, critical: true },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
{ name: "extKeyUsage", serverAuth: true },
{ name: "subjectAltName", altNames: [{ type: 2, value: hostname }] },
{ name: "authorityKeyIdentifier", keyIdentifier: caSki },
],
2,
);
cert.sign(ca.key, forge.md.sha256.create());
return {
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
certPem: forge.pki.certificateToPem(cert),
fingerprint: certFingerprint(forge.pki.certificateToPem(cert)),
};
}
// SHA-256 over the DER encoding, hex — what a "tls" chain record pins.
export function certFingerprint(certPem) {
const der = forge.asn1
.toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem)))
.getBytes();
const md = forge.md.sha256.create();
md.update(der);
return md.digest().toHex();
}

141
src/lib/chain.js Normal file
View file

@ -0,0 +1,141 @@
// Chain queries for the registration flow: address -> UTXOs, broadcast, lookup.
// libauth only (no node builtins) so it runs in the browser and in Node.
import {
cashAddressToLockingBytecode,
lockingBytecodeToCashAddress,
sha256,
hash160,
binToHex,
hexToBin,
} from "@bitauth/libauth";
// Electrum indexes by "scripthash": sha256(lockingBytecode), byte-reversed, hex.
export function lockingBytecodeToScripthash(lockingBytecode) {
return binToHex(sha256.hash(lockingBytecode).slice().reverse());
}
export function addressToLockingBytecode(address) {
const r = cashAddressToLockingBytecode(address);
if (typeof r === "string") throw new Error(`bad address "${address}": ${r}`);
return r.bytecode;
}
export function addressToScripthash(address) {
return lockingBytecodeToScripthash(addressToLockingBytecode(address));
}
// Re-encode any cashaddr as its token-aware ("token address") form, which is
// what a certificate output must be sent to. Same locking bytecode either way;
// the flag only tells wallets the address is safe to receive CashTokens on.
export function toTokenAddress(address) {
const bytecode = addressToLockingBytecode(address);
const prefix = String(address).includes(":") ? address.split(":")[0] : "bchtest";
const r = lockingBytecodeToCashAddress({ bytecode, prefix, tokenSupport: true });
if (typeof r === "string") throw new Error(`cannot token-encode "${address}": ${r}`);
return r.address;
}
export function stripTokenAddress(address) {
const bytecode = addressToLockingBytecode(address);
const prefix = String(address).includes(":") ? address.split(":")[0] : "bchtest";
const r = lockingBytecodeToCashAddress({ bytecode, prefix, tokenSupport: false });
if (typeof r === "string") throw new Error(`cannot encode "${address}": ${r}`);
return r.address;
}
// Fetch spendable outputs for one address.
// Returns [{ txid, vout, satoshis: bigint, address, lockingBytecode, token? }]
// `token` is present (and the UTXO is NOT safe to spend as plain change) when
// the output carries CashTokens.
export async function getUtxos(electrum, address) {
const lockingBytecode = addressToLockingBytecode(address);
const scripthash = lockingBytecodeToScripthash(lockingBytecode);
const list = await electrum.call("blockchain.scripthash.listunspent", [scripthash, "include_tokens"]);
return list.map((u) => ({
txid: u.tx_hash,
vout: u.tx_pos,
satoshis: BigInt(u.value),
height: u.height,
address,
lockingBytecode,
token: u.token_data
? {
category: u.token_data.category,
amount: BigInt(u.token_data.amount ?? 0),
nft: u.token_data.nft
? { capability: u.token_data.nft.capability, commitment: u.token_data.nft.commitment }
: undefined,
}
: undefined,
}));
}
export async function getUtxosForAddresses(electrum, addresses) {
const out = [];
for (const a of addresses) out.push(...(await getUtxos(electrum, a)));
return out;
}
export async function broadcast(electrum, txHex) {
return electrum.call("blockchain.transaction.broadcast", [txHex]);
}
export async function getTransaction(electrum, txid, verbose = true) {
return electrum.call("blockchain.transaction.get", [txid, verbose]);
}
/**
* Who paid us — recovered from the transaction itself.
*
* A refund needs a destination, and the only address a payer has *provably*
* controlled is the one they spent from: to create the input they signed with
* its key. So rather than asking (and being told a typo, or nothing at all),
* read the compressed public key straight out of the P2PKH unlocking script and
* derive the address from it.
*
* A standard P2PKH unlocking script is exactly two pushes:
* <push sig (71-73 bytes DER, or 65 schnorr)> <push pubkey (33 or 65)>
* so the last push is the public key. Anything else — multisig, a covenant, a
* non-standard script — returns null rather than a guess, because refunding to a
* wrong address is worse than not refunding automatically.
*/
export function payerAddressFromInput(scriptSigHex, prefix = "bchtest") {
if (!scriptSigHex) return null;
const script = hexToBin(scriptSigHex);
const pushes = [];
let i = 0;
while (i < script.length) {
const op = script[i++];
let len;
if (op >= 0x01 && op <= 0x4b) len = op;
else if (op === 0x4c) len = script[i++];
else if (op === 0x4d) { len = script[i] | (script[i + 1] << 8); i += 2; }
else return null; // opcodes other than pushes -> not a plain P2PKH spend
if (i + len > script.length) return null;
pushes.push(script.slice(i, i + len));
i += len;
}
if (pushes.length !== 2) return null;
const pubkey = pushes[1];
if (pubkey.length !== 33 && pubkey.length !== 65) return null;
const r = lockingBytecodeToCashAddress({
bytecode: Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(pubkey), 0x88, 0xac]),
prefix,
tokenSupport: false,
});
return typeof r === "string" ? null : r.address;
}
/** The paying address of a transaction, read from its first spendable input. */
export async function payerAddressOf(electrum, txid, prefix = "bchtest") {
const tx = await getTransaction(electrum, txid, true);
for (const vin of tx?.vin ?? []) {
const addr = payerAddressFromInput(vin?.scriptSig?.hex, prefix);
if (addr) return addr;
}
return null;
}
export { binToHex, hexToBin };

View file

@ -0,0 +1,69 @@
// The seam between "which wallet is the buyer using" and "here is the
// registration transaction to sign".
//
// Every path — built-in, WizardConnect, WalletConnect v2 — produces the same
// `{transaction, sourceOutputs}` from register-tx.js. Only the signing
// transport differs, and that difference lives entirely behind this interface.
//
// interface WalletSession {
// label: string
// getAddresses(): Promise<string[]> // prefixed cashaddrs
// signTransaction(built, opts): Promise<{ signedTransaction, signedTransactionHash }>
// disconnect(): Promise<void>
// }
//
// `built` is the object returned by buildRegistrationTx / buildUpdateTx.
// `opts` is { broadcast, userPrompt }.
import { hash160, lockingBytecodeToCashAddress, binToHex } from "@bitauth/libauth";
/** Compressed pubkey -> P2PKH locking bytecode. */
export function pubkeyToLockingBytecode(publicKey) {
return Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(publicKey), 0x88, 0xac]);
}
/** Compressed pubkey -> cashaddr for the given prefix. */
export function pubkeyToAddress(publicKey, prefix = "bchtest", tokenSupport = false) {
const r = lockingBytecodeToCashAddress({
bytecode: pubkeyToLockingBytecode(publicKey),
prefix,
tokenSupport,
});
if (typeof r === "string") throw new Error(`address encoding failed: ${r}`);
return r.address;
}
export { binToHex };
/**
* The genesis-input problem, stated once for every external-wallet path.
*
* CashTokens will only create a new certificate category in a transaction that
* spends an outpoint at index 0. With the built-in wallet we simply make one.
* With someone else's wallet we cannot move their coins unasked, so:
*
* 1. Scan every address the wallet exposes and look for a coin at vout 0.
* WizardConnect makes this cheap — it hands over xpubs, so we can derive
* and check a whole window of addresses locally with no round-trips.
* WalletConnect v2 exposes fewer addresses, so the window is smaller.
* 2. If one exists, use it. This is the common case: any wallet that has ever
* received a payment as output 0 of some transaction qualifies.
* 3. If none exists, ask the wallet to sign one extra "prepare" transaction —
* a self-send whose output 0 returns to the user. Two approvals instead of
* one, clearly explained, and only ever needed once per wallet.
*
* Registrations always place their change at vout 0, so after a buyer's first
* name the wallet is permanently genesis-capable and step 3 never recurs.
*/
export function describeGenesisRequirement(hasCoin) {
return hasCoin
? { ready: true, approvals: 1, explanation: "Your wallet can mint the certificate directly." }
: {
ready: false,
approvals: 2,
explanation:
"Your wallet needs one small setup transaction to itself before it can create a " +
"certificate — a rule of the Bitcoin Cash token system, not a fee we charge. " +
"You will be asked to approve it, then the registration. This happens only once.",
};
}

View file

@ -0,0 +1,178 @@
// WizardConnect (protocol `hdwalletv1`) — Nostr transport, WC2-shaped payload.
//
// LICENCE (see Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md):
// @wizardconnect/core and /dapp are LGPL-3.0-or-later — free for commercial
// use, but §4(d) requires that the user be able to substitute their own build
// of the library. We satisfy that with §4(d)(1): WizardConnect is loaded as a
// SEPARATE, UNMINIFIED ES module via dynamic import() and is never inlined into
// the main bundle. Do not "optimise" this into a static import.
//
// Why it's worth having: the wallet hands over xpubs at handshake, so the dApp
// derives addresses locally with no further round-trips — which is exactly what
// the genesis-input scan needs, and it leaks far less to the relay than
// per-address queries. Transport is NIP-17 gift-wrapped, so the relay sees only
// ciphertext.
//
// Pinned against @wizardconnect/core@0.2.4 / @wizardconnect/dapp@0.2.2.
// NOTE ON THE MISSING libauth IMPORT — this is deliberate, do not "fix" it.
//
// This module is bundled on its own (LGPL §4d1, above). If it also imported
// libauth, the page would ship a second copy, and that copy would have its own
// top-level-await WASM initialisation. esbuild can hand out this module's
// exports before that init settles, which showed up as
// `Cannot read properties of undefined (reading 'derivePublicKeyCompressed')`
// on first load and then mysteriously worked on every retry.
//
// So the crypto helpers are injected by the caller from the main bundle, where
// libauth is already initialised. One copy of libauth, no race, and this bundle
// stays what it claims to be: WizardConnect and nothing else.
// Path name -> BIP44 branch index (childIndexOfPathName in hdwalletv1).
export const PATHS = { receive: 0, change: 1, defi: 7 };
export const ADDRESS_WINDOW = 20;
/**
* Open a WizardConnect session.
* Returns immediately with the URI to show as a QR; `ready` resolves when the
* user's wallet has completed the handshake.
*
* @param {object} o
* @param {string} o.dappName
* @param {string} [o.dappIcon]
* @param {string} [o.prefix] cashaddr prefix, "bchtest" on chipnet
* @param {object} o.crypto injected from the main bundle:
* { pubkeyToAddress, pubkeyToLockingBytecode, binToHex, txidOfHex }
*/
export async function openWizardSession({ dappName, dappIcon, prefix = "bchtest", crypto } = {}) {
const { pubkeyToAddress, pubkeyToLockingBytecode, binToHex, txidOfHex } = crypto ?? {};
if (!pubkeyToAddress || !pubkeyToLockingBytecode || !binToHex || !txidOfHex) {
throw new Error(
"openWizardSession needs `crypto: { pubkeyToAddress, pubkeyToLockingBytecode, binToHex, " +
"txidOfHex }` injected from the main bundle — see the note at the top of this file",
);
}
// Dynamic import: keeps LGPL code a separate, replaceable module (and keeps
// it off the wire entirely for buyers using the built-in wallet).
const [{ initiateDappRelay }, { DappConnectionManager }] = await Promise.all([
import("@wizardconnect/core"),
import("@wizardconnect/dapp"),
]);
const manager = new DappConnectionManager(dappName, dappIcon);
const relay = initiateDappRelay((payload) => {
manager.updateConnection(payload.client, payload.status);
});
manager.attachRelay(relay);
let resolveReady, rejectReady;
const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
manager.once("walletready", (msg) => resolveReady({ walletName: msg?.wallet_name ?? null }));
manager.once("disconnect", (reason, message) =>
rejectReady(new Error(`wallet disconnected: ${message ?? reason}`)));
/** Derive one address from the wallet's xpubs. */
const addressAt = (pathName, index) => {
const pubkey = manager.getPubkey(PATHS[pathName], BigInt(index));
if (!pubkey) return null;
return {
pathName,
index,
publicKey: pubkey,
address: pubkeyToAddress(pubkey, prefix, false),
tokenAddress: pubkeyToAddress(pubkey, prefix, true),
lockingBytecode: pubkeyToLockingBytecode(pubkey),
};
};
/** Every address we can see, so the genesis-input scan has something to work with. */
const derivedAddresses = (window = ADDRESS_WINDOW) => {
const out = [];
for (const pathName of ["receive", "change"]) {
if (!manager.hasPath(PATHS[pathName])) continue;
for (let i = 0; i < window; i++) {
const a = addressAt(pathName, i);
if (a) out.push(a);
}
}
return out;
};
return {
label: "WizardConnect",
protocol: "hdwalletv1",
uri: relay.uri, // wiz://?p=…&s=…
qrUri: relay.qrUri, // QR-alphanumeric-safe variant
ready,
manager,
// hdwalletv1's `sign_transaction_response` carries ONLY `signedTransaction`
// (+ optional `error`) — there is no `signedTransactionHash`, and the wallet
// SDK has no broadcast step at all. So the dApp broadcasts, and we ask for
// `broadcast: false` to stop a wallet that reads the WC2-shaped payload's
// flag from doing it too. The registrar tolerates a duplicate anyway.
walletBroadcasts: false,
async getAddresses() {
if (!manager.isWalletDiscovered()) await ready;
return derivedAddresses().map((a) => a.address);
},
/** The full derived set, with the HD metadata `inputPaths` needs. */
async getDerivedAddresses(window) {
if (!manager.isWalletDiscovered()) await ready;
return derivedAddresses(window);
},
/**
* Sign a transaction built by register-tx.js.
*
* WizardConnect's differentiator is `inputPaths`: rather than the wallet
* guessing which key owns each input, we state it — [inputIndex, pathName,
* addressIndex]. The wallet re-derives each child key and checks it really
* does produce the source output's locking bytecode before signing, so a
* wrong triplet is a hard reject rather than a silent mis-sign.
*
* Note the wallet signs with SIGHASH_ALL|FORKID|UTXOS (stricter than WC2's
* ALL|FORKID). That is the wallet's business — our transaction is unchanged.
*/
async signTransaction(built, { userPrompt } = {}) {
if (!manager.isWalletDiscovered()) await ready;
const known = new Map(derivedAddresses().map((a) => [binToHex(a.lockingBytecode), a]));
const inputPaths = built.sourceOutputs.map((src, inputIndex) => {
const a = known.get(binToHex(src.lockingBytecode));
if (!a) {
throw new Error(
`input ${inputIndex} is not from this wallet — refusing to ask it to sign a coin it does not own`,
);
}
return [inputIndex, a.pathName, a.index];
});
const response = await manager.signTransaction({
transaction: {
transaction: built.transaction,
sourceOutputs: built.sourceOutputs,
broadcast: false, // we broadcast — see `walletBroadcasts` above
...(userPrompt ? { userPrompt } : {}),
},
inputPaths,
});
if (response?.error) throw new Error(`wallet refused to sign: ${response.error}`);
if (!response?.signedTransaction) throw new Error("wallet returned no signed transaction");
return {
signedTransaction: response.signedTransaction,
// Derived locally: hdwalletv1 does not send the txid back.
signedTransactionHash: txidOfHex(response.signedTransaction),
};
},
async disconnect() {
try { manager.clearStoredSession(); } catch { /* nothing stored */ }
try { await manager.sendDisconnect("registration finished"); } catch { /* already gone */ }
try { manager.destroy(); } catch { /* already torn down */ }
try { relay.cleanup(); } catch { /* already cleaned */ }
},
};
}

56
src/lib/dns-patch.js Normal file
View file

@ -0,0 +1,56 @@
// dns-patch — swap dns.lookup for a c-ares-backed implementation.
//
// Why: on some Windows configurations, the OS getaddrinfo path silently drops
// specific hostnames (chipnet.bch.ninja / chipnet.imaginary.cash /
// chipnet.c3-soft.com have been observed to return EAI_FAIL in ~100 ms while
// nslookup and Node's dns.resolve4 resolve them fine). Every dependency
// downstream — mainnet-js, ws, https — uses dns.lookup by default, so patching
// it here fixes registration, resolution, updates, and Sia uploads in one shot.
//
// The patch is idempotent (guarded by a symbol) and applied at import time.
// Consumers just `import "./dns-patch.js"` before anything that hits the net.
//
// If the OS resolver is healthy, this is a no-op in effect: c-ares queries
// public resolvers directly (1.1.1.1 / 8.8.8.8 / 9.9.9.9). The one behavior
// change is that /etc/hosts and Windows hosts-file entries stop being honoured
// — we fall back to the OS resolver as a last resort so those still work.
import dns from "node:dns";
const PATCHED = Symbol.for("bnsd.dnsLookupPatched");
if (!globalThis[PATCHED]) {
globalThis[PATCHED] = true;
dns.setServers(["1.1.1.1", "8.8.8.8", "9.9.9.9"]);
const origLookup = dns.lookup;
dns.lookup = (hostname, opts, cb) => {
if (typeof opts === "function") { cb = opts; opts = {}; }
else if (typeof opts === "number") opts = { family: opts };
const wantsAll = !!opts?.all;
const wantV6 = opts?.family === 6;
const first = wantV6 ? "resolve6" : "resolve4";
const second = wantV6 ? "resolve4" : "resolve6";
const fam1 = wantV6 ? 6 : 4;
const fam2 = wantV6 ? 4 : 6;
dns[first](hostname, (err1, addrs1) => {
if (!err1 && addrs1?.length) {
return wantsAll
? cb(null, addrs1.map((a) => ({ address: a, family: fam1 })))
: cb(null, addrs1[0], fam1);
}
if (opts?.family === 4 || opts?.family === 6) {
return origLookup(hostname, opts, cb); // caller pinned a family; fall through to OS
}
dns[second](hostname, (err2, addrs2) => {
if (!err2 && addrs2?.length) {
return wantsAll
? cb(null, addrs2.map((a) => ({ address: a, family: fam2 })))
: cb(null, addrs2[0], fam2);
}
origLookup(hostname, opts, cb); // last resort: honour hosts file / VPN split DNS
});
});
};
}

225
src/lib/electrum.js Normal file
View file

@ -0,0 +1,225 @@
// Zero-dependency electrum (Fulcrum) JSON-RPC client over a WebSocket.
// Browser: uses the native WebSocket. Node: pass `ws`'s WebSocket in.
//
// This is the same client that is inlined in `resolver-web.js`. That file is
// deliberately kept as a single zero-dependency drop-in (it gets copied whole
// into the extension / mobile webview), so the duplication is intentional —
// change both together.
// Electrum servers speak wss on non-standard ports (50004, 62002). Corporate
// networks, some VPNs and some mobile carriers block those outright, and when
// they do, EVERY server in this list fails at once — one firewall rule takes the
// whole resolver down. A server reachable on 443 is therefore worth more than
// three more on 50004; put your own there and list it first.
export const CHIPNET_ELECTRUM = [
"wss://chipnet.bch.ninja:50004",
"wss://chipnet.imaginary.cash:50004",
"wss://tbch4.loping.net:62002",
];
export class Electrum {
constructor(url, WebSocketImpl) {
this.url = url;
this.WS = WebSocketImpl || globalThis.WebSocket;
this.id = 0;
this.pending = new Map();
this.buf = "";
}
connect() {
return new Promise((resolve, reject) => {
const ws = new this.WS(this.url);
this.ws = ws;
const onOpen = async () => {
try { await this.call("server.version", ["bns-web", "1.4.2"]); resolve(this); }
catch (e) { reject(e); }
};
const onErr = (e) => reject(new Error("electrum ws error: " + (e?.message || this.url)));
const onMsg = (ev) => this._onData(typeof ev.data === "string" ? ev.data : ev.data.toString());
if (ws.addEventListener) {
ws.addEventListener("open", onOpen);
ws.addEventListener("error", onErr);
ws.addEventListener("message", onMsg);
} else { ws.onopen = onOpen; ws.onerror = onErr; ws.onmessage = onMsg; }
});
}
_handle(msg) {
const p = this.pending.get(msg.id);
if (!p) return;
this.pending.delete(msg.id);
msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result);
}
// Fulcrum-over-WS is newline-framed, but a lone frame may arrive without one.
_onData(chunk) {
this.buf += chunk;
let nl;
while ((nl = this.buf.indexOf("\n")) >= 0) {
const line = this.buf.slice(0, nl).trim();
this.buf = this.buf.slice(nl + 1);
if (line) { try { this._handle(JSON.parse(line)); } catch { /* partial */ } }
}
const rest = this.buf.trim();
if (rest) { try { const m = JSON.parse(rest); this._handle(m); this.buf = ""; } catch { /* partial */ } }
}
call(method, params = []) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }) + "\n");
setTimeout(() => {
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
}, 20000);
});
}
close() { try { this.ws.close(); } catch { /* already gone */ } }
}
/**
* Connect to the first electrum server that answers.
*
* On total failure this reports every server and why each one failed, rather
* than only the last error. That matters because the failures look nothing like
* "the network is down": a blocked port surfaces as a WebSocket handshake
* "Parse Error: Expected HTTP/", which reads like a bug in this file. Electrum
* runs on non-standard ports (50004, 62002) that corporate networks, some VPNs
* and some mobile carriers block outright, so "all servers unreachable" is a
* routine condition for real users and deserves an actionable message.
*/
export async function connectAny(urls = CHIPNET_ELECTRUM, WebSocketImpl) {
const failures = [];
for (const url of urls) {
try { return await new Electrum(url, WebSocketImpl).connect(); }
catch (e) { failures.push(`${url}: ${e?.message ?? e}`); }
}
throw unreachable(urls.length, failures);
}
function unreachable(tried, failures) {
const err = new Error(
`no electrum server reachable (tried ${tried}). These use non-standard ` +
`ports, which some networks and VPNs block:\n ${failures.join("\n ")}`,
);
err.code = "NO_ELECTRUM";
err.failures = failures;
return err;
}
/**
* A pool of electrum connections.
*
* Two independent reasons this exists, and they want different behaviour:
*
* **Availability** — connect to several servers at once rather than trying
* them one at a time, so a dead or slow server costs nothing. `call` uses the
* first healthy connection and transparently fails over.
*
* **Integrity** — a single electrum server is a trusted third party. It cannot
* forge a registration (we verify the certificate genesis and the beacon
* payment ourselves), but it CAN quietly omit one. Since the registry rule is
* "first valid REG wins", a hidden REG makes a taken name look free: the buyer
* pays, mints a duplicate, and every other resolver rejects it. `callAll` +
* `unionHistory` defend against exactly that, because omission is the only
* cheap attack and the union of several servers' histories is at least as
* complete as the most honest one among them.
*/
export class ElectrumPool {
constructor(clients) {
if (!clients.length) throw new Error("ElectrumPool needs at least one client");
this.clients = clients;
}
get urls() { return this.clients.map((c) => c.url); }
get size() { return this.clients.length; }
/** First healthy server wins; on failure, transparently try the next. */
async call(method, params = []) {
const failures = [];
for (const client of this.clients) {
try { return await client.call(method, params); }
catch (e) { failures.push(`${client.url}: ${e?.message ?? e}`); }
}
throw unreachable(this.clients.length, failures);
}
/** Ask every server. Returns per-server outcomes — never throws on a partial failure. */
async callAll(method, params = []) {
return Promise.all(
this.clients.map(async (client) => {
try { return { url: client.url, ok: true, result: await client.call(method, params) }; }
catch (e) { return { url: client.url, ok: false, error: e?.message ?? String(e) }; }
}),
);
}
/**
* Union of `blockchain.scripthash.get_history` across the pool.
*
* Union rather than quorum, deliberately: the attack is omission, so a server
* showing MORE history is showing more truth — a majority vote would let two
* colluding (or two identically stale) servers erase a registration. Every
* transaction returned still has to pass the resolver's own validity rules, so
* a server cannot inject a name by adding entries here.
*
* `disagreements` is reported rather than thrown on: servers legitimately
* differ at the mempool edge, and the caller decides whether the gap matters.
*/
async unionHistory(scripthash) {
const responses = await this.callAll("blockchain.scripthash.get_history", [scripthash]);
const answered = responses.filter((r) => r.ok);
if (!answered.length) {
throw unreachable(this.clients.length, responses.map((r) => `${r.url}: ${r.error}`));
}
const merged = new Map(); // tx_hash -> entry, preferring a confirmed height
for (const { result } of answered) {
for (const entry of result ?? []) {
const seen = merged.get(entry.tx_hash);
// A confirmed height beats an unconfirmed one; otherwise keep the lowest.
if (!seen || (seen.height <= 0 && entry.height > 0) ||
(entry.height > 0 && seen.height > 0 && entry.height < seen.height)) {
merged.set(entry.tx_hash, entry);
}
}
}
const disagreements = answered
.map(({ url, result }) => {
const missing = [...merged.keys()].filter(
(h) => !(result ?? []).some((e) => e.tx_hash === h),
);
return missing.length ? { url, missing } : null;
})
.filter(Boolean);
return {
history: [...merged.values()],
servers: answered.length,
unreachable: responses.filter((r) => !r.ok),
disagreements,
};
}
close() { for (const c of this.clients) { try { c.close(); } catch { /* already gone */ } } }
}
/**
* Connect to several servers concurrently and return a pool.
*
* @param {number} [o.want] how many connections to keep (more = better omission
* defence, at the cost of more sockets). 1 disables cross-checking.
* @param {number} [o.need] minimum before we give up entirely.
*/
export async function connectPool(urls = CHIPNET_ELECTRUM, WebSocketImpl, { want = 3, need = 1 } = {}) {
const settled = await Promise.all(
urls.slice(0, Math.max(want, need)).map(async (url) => {
try { return { ok: true, client: await new Electrum(url, WebSocketImpl).connect() }; }
catch (e) { return { ok: false, url, error: e?.message ?? String(e) }; }
}),
);
const clients = settled.filter((s) => s.ok).map((s) => s.client);
if (clients.length < need) {
for (const c of clients) { try { c.close(); } catch { /* ignore */ } }
throw unreachable(urls.length, settled.filter((s) => !s.ok).map((s) => `${s.url}: ${s.error}`));
}
return new ElectrumPool(clients);
}

29
src/lib/gen-wallets.js Normal file
View file

@ -0,0 +1,29 @@
// One-time setup: generate the registrant wallet and the registry beacon wallet,
// persist seeds to wallets.json, and verify chipnet electrum connectivity.
import { TestNetWallet } from "mainnet-js";
import { writeFileSync, existsSync, readFileSync } from "fs";
const FILE = new URL("../../wallets.json", import.meta.url);
let store;
if (existsSync(FILE)) {
store = JSON.parse(readFileSync(FILE, "utf8"));
} else {
const main = await TestNetWallet.newRandom();
const beacon = await TestNetWallet.newRandom();
store = {
main: { seed: main.mnemonic, path: main.derivationPath },
beacon: { seed: beacon.mnemonic, path: beacon.derivationPath },
};
writeFileSync(FILE, JSON.stringify(store, null, 2));
}
const main = await TestNetWallet.fromSeed(store.main.seed, store.main.path);
const beacon = await TestNetWallet.fromSeed(store.beacon.seed, store.beacon.path);
console.log("network:", main.network);
console.log("main cashaddr: ", main.cashaddr);
console.log("main tokenaddr: ", main.tokenaddr);
console.log("beacon cashaddr:", beacon.cashaddr);
console.log("main balance (sat):", await main.getBalance("sat"));
process.exit(0);

18
src/lib/issue-cert.js Normal file
View file

@ -0,0 +1,18 @@
// Usage: node src/lib/issue-cert.js <hostname>
// Writes ca/<hostname>.key + .crt (signed by the BNS local root) and prints
// the SHA-256 fingerprint to publish as the name's "tls" chain record.
import { writeFileSync } from "fs";
import { fileURLToPath } from "url";
import { issueCert } from "./ca.js";
const [hostname] = process.argv.slice(2);
if (!hostname) {
console.error("usage: node src/lib/issue-cert.js <hostname>");
process.exit(1);
}
const { keyPem, certPem, fingerprint } = issueCert(hostname);
const dir = fileURLToPath(new URL("../../ca/", import.meta.url));
writeFileSync(dir + hostname + ".key", keyPem);
writeFileSync(dir + hostname + ".crt", certPem);
console.log(`written: ca/${hostname}.key, ca/${hostname}.crt`);
console.log(`sha256 fingerprint: ${fingerprint}`);

278
src/lib/orders.js Normal file
View file

@ -0,0 +1,278 @@
// Pay-by-QR orders: quote a name, hand out a payment address, watch for the
// money, then mint the certificate straight to the buyer.
//
// WHY THIS EXISTS
// The self-custody flow asks the buyer to sign the minting transaction, which
// means they need a wallet that speaks a connector protocol. This flow asks for
// nothing but the ability to send a payment — so Electron Cash, a hardware
// wallet, or an exchange withdrawal all work. The trade is a real one and the
// UI must not hide it: the buyer pays first and trusts the registrar to mint.
//
// What the buyer does NOT have to trust: custody of the name. The certificate is
// minted directly to the address they gave, in a single transaction, so it never
// sits in a registrar-controlled output for even one block. The registrar's
// exposure is "took the money and did not mint" — an ordinary merchant risk —
// not "holds your name".
//
// ONE DEPOSIT ADDRESS PER ORDER. The obvious shortcut is a single address plus
// amount-matching, and it breaks the first time two buyers are quoted the same
// price in the same minute: there is no way to tell whose payment is whose.
// Deriving a fresh address per order from an HD path makes the address itself
// the order identifier, which is unambiguous and lets the public side of the
// service run from an xpub with no private key anywhere near it.
import {
deriveHdPath,
deriveHdPathRelative,
deriveHdPublicNode,
decodeHdPublicKey,
secp256k1,
} from "@bitauth/libauth";
import { pubkeyToAddress } from "./connect/wallet-session.js";
import { addressToLockingBytecode, toTokenAddress, getUtxos, hexToBin } from "./chain.js";
import { normalizeName, displayName, encodeTransaction } from "./register-tx.js";
/**
* Order lifecycle.
*
* quoted ──paid──▶ awaitingSignature ──signed──▶ minted
* │ │
* └──── expired ◀──────┴── (never signed) ──▶ refunding ──▶ refunded
*
* `awaitingSignature` is the gate that makes this safe. The destination wallet
* is generated in the buyer's browser at purchase time, so it is always a key
* they hold — but only for as long as they still have it. A buyer who clears
* the browser without saving the recovery phrase would otherwise receive an
* irrevocable certificate locked to a key that no longer exists anywhere.
*
* Requiring a signature immediately before minting turns that silent, permanent
* loss into a refund. It must come BEFORE the mint: asking the buyer to confirm
* receipt afterwards is exploitable, since the name is already theirs and
* withholding the confirmation would win back the money too.
*/
export const ORDER_STATUS = {
quoted: "quoted", // address handed out, nothing received
paid: "paid", // funds seen
awaitingSignature: "awaiting-signature", // proving the buyer still holds the destination key
minted: "minted", // certificate broadcast to the buyer
refunding: "refunding", // unsigned, expired, or name taken — money going back
refunded: "refunded",
expired: "expired", // quote lapsed with no payment
};
/** How long a quote holds. Names are first-come, so a quote is not a reservation. */
export const QUOTE_TTL_MS = 60 * 60 * 1000; // 1 hour
// Deposit addresses live on their own BIP44 branch so they can never collide
// with the registrar's spending coins. 145 = BCH.
export const DEPOSIT_BRANCH = 3;
export const DEPOSIT_PATH = (index) => `m/44'/145'/0'/${DEPOSIT_BRANCH}/${index}`;
/**
* Derive the deposit address for an order index.
*
* Two modes, deliberately:
* - `xpub` — the public side of the service derives addresses with NO private
* key present. This is the mode a public server should run in.
* - `hdNode` — a private node, for the operator-side minting process.
*/
export function depositAddressAt(index, { xpub, hdNode, prefix = "bchtest" }) {
if (xpub) {
const decoded = decodeHdPublicKey(xpub);
if (typeof decoded === "string") throw new Error(`bad xpub: ${decoded}`);
// Relative, not absolute: the xpub is the branch node at depth 4, and
// absolute derivation ("M/…") is only valid from a depth-0 master node.
const child = deriveHdPathRelative(decoded.node, `${index}`);
if (typeof child === "string") throw new Error(`derivation failed: ${child}`);
return pubkeyToAddress(child.publicKey, prefix, false);
}
if (hdNode) {
const child = deriveHdPath(hdNode, DEPOSIT_PATH(index));
if (typeof child === "string") throw new Error(`derivation failed: ${child}`);
const publicKey = secp256k1.derivePublicKeyCompressed(child.privateKey);
if (typeof publicKey === "string") throw new Error(`pubkey failed: ${publicKey}`);
return pubkeyToAddress(publicKey, prefix, false);
}
throw new Error("depositAddressAt needs either an xpub or an hdNode");
}
/** The branch-level xpub a public order service needs — and nothing more. */
export function depositXpub(rootNode) {
const branch = deriveHdPath(rootNode, `m/44'/145'/0'/${DEPOSIT_BRANCH}`);
if (typeof branch === "string") throw new Error(`derivation failed: ${branch}`);
return deriveHdPublicNode(branch);
}
/**
* BIP21 payment URI — what the QR encodes.
*
* Every BCH wallet understands `bitcoincash:<addr>?amount=<BCH>`. The amount is
* in BCH, not satoshis, and trailing zeros must go: some wallets reject
* "0.00011000" while accepting "0.00011".
*/
export function bip21({ address, satoshis, label, message }) {
const bch = (Number(satoshis) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
const params = [`amount=${bch}`];
if (label) params.push(`label=${encodeURIComponent(label)}`);
if (message) params.push(`message=${encodeURIComponent(message)}`);
// The cashaddr already carries its own "bchtest:"/"bitcoincash:" prefix.
return `${address}?${params.join("&")}`;
}
/**
* What a buyer pays: the service fee plus everything the mint itself costs.
*
* The registrar funds the minting transaction, so the buyer must cover the chain
* fee, the beacon dust and the satoshis that end up locked inside the
* certificate — otherwise every sale loses money slowly. Rounded up to a tidy
* number so the amount in the QR looks like a price rather than an invoice.
*/
export function quotePrice({ serviceFeeSats, estimatedChainFeeSats = 500n, certificateDustSats = 1000n, beaconDustSats = 600n, roundToSats = 1000n }) {
const raw = BigInt(serviceFeeSats) + BigInt(estimatedChainFeeSats) + BigInt(certificateDustSats) + BigInt(beaconDustSats);
if (roundToSats <= 1n) return raw;
const r = BigInt(roundToSats);
return ((raw + r - 1n) / r) * r;
}
/**
* Validate and normalise the address a buyer wants the certificate sent to.
*
* This is the single most dangerous field in the flow: a typo, or an address
* from a wallet that cannot hold CashTokens, and the name is gone. So we
* normalise to the token-aware form ourselves rather than asking the buyer to
* understand the difference between `q…` and `z…`.
*/
export function normaliseBuyerAddress(input, { prefix = "bchtest" } = {}) {
const raw = String(input ?? "").trim();
if (!raw) throw new Error("enter the address the name should be sent to");
const withPrefix = raw.includes(":") ? raw : `${prefix}:${raw}`;
// Check the network BEFORE decoding. A cashaddr checksum covers its prefix, so
// a mainnet address fails to decode against a chipnet expectation and would
// otherwise be reported as a typo — sending someone hunting for a
// mistyped character when the real problem is that they are on the wrong chain.
const gotPrefix = withPrefix.slice(0, withPrefix.indexOf(":")).toLowerCase();
if (gotPrefix !== prefix) {
const networkOf = (p) => (p === "bchtest" ? "chipnet" : p === "bitcoincash" ? "mainnet" : p);
throw new Error(
`that is a ${networkOf(gotPrefix)} address, but this registry is on ${networkOf(prefix)} — ` +
`a certificate sent there would be lost`,
);
}
let lockingBytecode;
try {
lockingBytecode = addressToLockingBytecode(withPrefix);
} catch {
throw new Error("that does not look like a Bitcoin Cash address — check for a typo");
}
return {
address: withPrefix,
tokenAddress: toTokenAddress(withPrefix),
lockingBytecode,
/** True when the buyer already gave a token-aware address — a good sign
* their wallet understands CashTokens, worth reflecting in the UI. */
wasTokenAware: raw.replace(/^.*:/, "").startsWith("z"),
};
}
/** Create an order. Storage is the caller's problem — this is pure. */
export function createOrder({ index, name, buyerAddress, priceSats, depositAddress, now }) {
const rawName = normalizeName(name);
const buyer = normaliseBuyerAddress(buyerAddress);
return {
index,
name: rawName,
displayName: displayName(rawName),
buyerAddress: buyer.address,
buyerTokenAddress: buyer.tokenAddress,
priceSats: BigInt(priceSats),
depositAddress,
status: ORDER_STATUS.quoted,
createdAt: now,
expiresAt: now + QUOTE_TTL_MS,
paymentTxid: null,
mintTxid: null,
category: null,
};
}
/**
* Has this order been paid? Reads the chain, not a webhook.
*
* Deliberately tolerant of overpayment and of payments split across several
* transactions — buyers do both, and refusing them creates support work. Under-
* payment is reported rather than treated as unpaid, so the UI can say how much
* is missing instead of silently waiting forever.
*/
export async function checkPayment(client, order) {
const utxos = await getUtxos(client, order.depositAddress);
const received = utxos.reduce((sum, u) => sum + u.satoshis, 0n);
const confirmed = utxos.filter((u) => u.height > 0).reduce((sum, u) => sum + u.satoshis, 0n);
return {
receivedSats: received,
confirmedSats: confirmed,
paid: received >= order.priceSats,
shortfallSats: received >= order.priceSats ? 0n : order.priceSats - received,
utxos,
txids: [...new Set(utxos.map((u) => u.txid))],
};
}
/** The signing key for an order's deposit address — operator side only. */
export function depositKeyAt(index, hdNode, prefix = "bchtest") {
const child = deriveHdPath(hdNode, DEPOSIT_PATH(index));
if (typeof child === "string") throw new Error(`derivation failed: ${child}`);
const publicKey = secp256k1.derivePublicKeyCompressed(child.privateKey);
if (typeof publicKey === "string") throw new Error(`pubkey failed: ${publicKey}`);
return {
privateKey: child.privateKey,
publicKey,
address: pubkeyToAddress(publicKey, prefix, false),
lockingBytecode: addressToLockingBytecode(pubkeyToAddress(publicKey, prefix, false)),
};
}
/**
* Sweep an order's deposit back to whoever paid it.
*
* Refunds send the entire balance minus the chain fee — there is no change
* output, because leaving dust behind at an address nobody will ever look at
* again is just a slower way of keeping the money.
*/
export function buildRefundTx({ utxos, toAddress, feeRate = 1, addressToLockingBytecode: toLock }) {
const coins = utxos.filter((u) => !u.token);
if (coins.length === 0) throw new Error("nothing to refund — the deposit address is empty");
const total = coins.reduce((s, u) => s + u.satoshis, 0n);
const lock = toLock(toAddress);
const inputs = coins.map((u) => ({
outpointTransactionHash: hexToBin(u.txid),
outpointIndex: u.vout,
sequenceNumber: 0xfffffffe,
unlockingBytecode: Uint8Array.from([0x41, ...new Uint8Array(65), 0x21, ...new Uint8Array(33)]),
}));
const sourceOutputs = coins.map((u, i) => ({
...inputs[i],
lockingBytecode: u.lockingBytecode,
valueSatoshis: u.satoshis,
}));
const build = (value) => ({
version: 2, locktime: 0, inputs,
outputs: [{ lockingBytecode: lock, valueSatoshis: value }],
});
const size = encodeTransaction(build(total)).length;
const fee = BigInt(Math.ceil(size * feeRate));
if (total <= fee + 546n) {
throw new Error(`deposit too small to refund: ${total} sat, fee would be ${fee} sat`);
}
return {
transaction: build(total - fee),
sourceOutputs,
inputUtxos: coins,
costs: { refundedSats: total - fee, chainFeeSats: fee, totalSats: total },
};
}
export { normalizeName, displayName };

164
src/lib/plans.js Normal file
View file

@ -0,0 +1,164 @@
// What Silent Mode actually sells, stated honestly.
//
// The register page shows four hosting options, but they are not four products.
// Three of them cost us nothing and are already free with any name: an `h`
// record is a transaction, an `ip`/`u` record is a pointer at the customer's own
// box, and a customer-run Sia is their renterd, not ours. Only **managed Sia**
// is hosting-as-a-service.
//
// This file keeps that distinction in the data rather than in the copy, so the
// UI cannot drift into implying we run a service we do not. Every plan declares:
//
// kind 'free' | 'managed' | 'referral'
// weStore do WE hold the customer's bytes? (the liability question)
// recurring does it lapse?
// onLapse what the buyer is left with when it does
//
// THE LAPSE STORY MATTERS MORE THAN THE PRICE. The strongest claim on the page
// is that a name needs no renewal, ever. Hosting does lapse. If the two blur
// together the flagship claim gets damaged by association, so every recurring
// plan must state plainly that the NAME is unaffected — it simply points nowhere
// until repointed, and repointing is free and needs nobody's permission.
export const PLAN_KIND = {
free: "free", // included with a name; costs us nothing ongoing
managed: "managed", // we store and serve — the only real service
referral: "referral", // someone else's product; we just link
};
/** Managed hosting is sold in prepaid periods. See ROADMAP note at the bottom. */
export const PERIOD = { year: "1y", quarter: "3m" };
export const HOSTING_PLANS = [
{
id: "onchain",
kind: PLAN_KIND.free,
title: "On the blockchain",
record: "h",
weStore: false,
recurring: false,
priceSats: 0n,
blurb:
"A small site stored inside the transaction itself. No server, no host, " +
"permanent as the chain.",
limits: "About 150 bytes of HTML — a page, a profile, a link hub.",
onLapse: null, // nothing to lapse
},
{
id: "own-server",
kind: PLAN_KIND.free,
title: "Your own server",
record: "ip",
weStore: false,
recurring: false,
priceSats: 0n,
blurb:
"Point the name straight at a machine you run. Works with anything you " +
"already host.",
limits: "You run the server; we never see the traffic.",
onLapse: null,
},
{
id: "byo-sia",
kind: PLAN_KIND.free,
title: "Your own Sia",
record: "s3",
weStore: false,
recurring: false,
priceSats: 0n,
blurb:
"Already running renterd, or paying a Sia provider? We set the record and " +
"step out of the way.",
limits: "You hold the storage contracts. Nothing of yours touches our infrastructure.",
onLapse: null,
},
{
id: "sia-managed-1y",
kind: PLAN_KIND.managed,
title: "Managed Sia hosting",
record: "s3",
weStore: true,
recurring: true,
period: PERIOD.year,
// Chipnet placeholder. Sia storage itself is pennies for a website; the cost
// here is operational, so this is priced as a flat period fee rather than
// per-gigabyte — pretending otherwise would be theatre.
priceSats: 250_000n,
quotaMB: 500,
blurb:
"Upload your files and we store them on the Sia network and serve them " +
"through the gateway. Encrypted and spread across independent hosts.",
limits: "500 MB, renewed yearly.",
onLapse:
"Your name is unaffected — it is yours permanently either way. If hosting " +
"lapses the site stops being served and the name simply points nowhere " +
"until you repoint it, which is free and needs nobody's permission.",
},
];
/** Third parties. Listed for convenience, sold by us in no sense whatsoever. */
export const VPS_REFERRALS = [
{ id: "mynymbox", title: "MyNymBox", blurb: "No-KYC VPS and hosting. Monero, Bitcoin, Lightning.", price: "from ~€3.50/mo", url: "https://mynymbox.io" },
{ id: "njalla", title: "Njalla", blurb: "Privacy-by-proxy domains and VPS. Crypto accepted.", price: "domains ~€15/yr", url: "https://njal.la" },
{ id: "1984", title: "1984 Hosting", blurb: "Iceland-based, privacy-respecting hosting.", price: "varies", url: "https://1984.hosting" },
];
export const planById = (id) => HOSTING_PLANS.find((p) => p.id === id) ?? null;
/** Plans a buyer actually pays us for. */
export const paidPlans = () => HOSTING_PLANS.filter((p) => p.priceSats > 0n);
/** Everything included free with a name — most of the list, and worth saying so. */
export const freePlans = () => HOSTING_PLANS.filter((p) => p.kind === PLAN_KIND.free);
/**
* Price a name, optionally with hosting, as ONE payment.
*
* A buyer purchasing both should pay once, to one address, not twice — the order
* system already carries a single deposit address per order, so a bundle is a
* line-item list rather than two orders to reconcile.
*
* `namePriceSats` comes from `quotePrice()` in orders.js, which already includes
* the chain costs of minting.
*/
export function quoteBundle({ namePriceSats, planId = null }) {
const lines = [{ id: "name", label: "Name certificate", sats: BigInt(namePriceSats) }];
const plan = planId ? planById(planId) : null;
if (planId && !plan) throw new Error(`unknown hosting plan "${planId}"`);
if (plan && plan.priceSats > 0n) {
lines.push({ id: plan.id, label: `${plan.title} (${plan.period ?? "one-off"})`, sats: plan.priceSats });
}
return {
lines,
plan,
totalSats: lines.reduce((s, l) => s + l.sats, 0n),
// True when the buyer is trusting us with bytes, not just with minting.
createsHostingObligation: !!plan?.weStore,
recurring: !!plan?.recurring,
};
}
/**
* ROADMAP — covenant subscriptions replace prepaid periods.
*
* Prepaid periods exist because they work today on the escrow that already
* ships. They are not the end state: BCH covenants can express a real
* subscription, which BTC cannot. A CashScript contract holds the buyer's funds
* and permits the host to withdraw `priceSats` once per period, enforced by the
* covenant re-creating itself under a relative timelock.
*
* That is strictly better than a card subscription in three ways a buyer can
* check for themselves: the balance stays theirs, the host structurally cannot
* take more than one period's fee per period, and cancellation is unilateral —
* they reclaim the remainder without asking anyone.
*
* It is the SAME primitive as the delivery-escrow covenant in
* `Decentralized.DNS/DESIGN-trustless-purchase.md` §4 — a contract that releases
* funds only on a condition. Design them together; building them separately
* would be doing the hard part twice.
*/
export const SUBSCRIPTION_ROADMAP = {
today: "prepaid periods on the existing escrow",
next: "CashScript covenant subscription (pull-per-period, cancel-anytime)",
sharesDesignWith: "Decentralized.DNS/DESIGN-trustless-purchase.md §4 (delivery escrow)",
};

549
src/lib/register-tx.js Normal file
View file

@ -0,0 +1,549 @@
// BNS1 name-certificate transaction builder — the single shared core behind all
// three wallet paths (built-in, WizardConnect, WalletConnect v2).
//
// Pure: no I/O, no wallet, no network. You hand it UTXOs and addresses, it hands
// back a libauth transaction plus the matching `sourceOutputs`, which together
// are exactly the WC2 `bch_signTransaction` payload shape
// (`@bch-wc2/interfaces` -> `WcSignTransactionRequest`). The built-in wallet
// signs that same structure locally; external wallets sign it over their
// transport. One shape, one set of consensus rules, one place to get it wrong.
//
// TERMINOLOGY: the token this mints is a *name certificate* — a consensus-
// enforced title to the name, like a deed. Not an "NFT".
//
// ---------------------------------------------------------------------------
// The genesis-input rule (the one consensus constraint that shapes everything)
// ---------------------------------------------------------------------------
// CashTokens lets a transaction create a new token category only if it spends
// an outpoint whose index is 0. The new category ID *is* that outpoint's
// transaction hash. So a registration cannot be built from arbitrary coins:
// the wallet must hold at least one UTXO sitting at vout 0.
//
// Two consequences, both handled here:
// 1. `selectInputs` puts a vout-0, token-free UTXO at input index 0 and
// throws `NoGenesisInputError` if the wallet has none — callers turn that
// into a one-off "prepare wallet" self-send (see `buildPrepTx`).
// 2. The change output is placed at **vout 0** of every registration, so the
// change from one registration is itself genesis-eligible. After a buyer's
// first registration the wallet is permanently self-perpetuating and no
// preparation transaction is ever needed again. This is why the output
// order below looks unconventional — it is deliberate.
import {
binToHex,
hexToBin,
utf8ToBin,
binToUtf8,
encodeTransaction,
decodeTransaction,
} from "@bitauth/libauth";
// ---------- protocol constants ----------
export const PREFIX = "BNS1";
export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
export const BEACON_DUST_SAT = 600n;
export const CERTIFICATE_DUST_SAT = 1000n; // token outputs need more than plain dust
export const P2PKH_DUST_SAT = 546n;
export const DEFAULT_TLD = "bch";
export const MAX_PAYLOAD_BYTES = 200;
const LABEL_RE = /^[a-z0-9-]{1,32}$/;
const TLD_RE = /^[a-z0-9-]{1,16}$/;
// A signed P2PKH unlocking script: push(64-byte schnorr sig + sighash byte)
// then push(33-byte compressed pubkey) = 1+65+1+33 = 100 bytes.
const P2PKH_UNLOCK_BYTES = 100;
export class NoGenesisInputError extends Error {
constructor(message) {
super(message);
this.name = "NoGenesisInputError";
this.code = "NO_GENESIS_INPUT";
}
}
export class InsufficientFundsError extends Error {
constructor(message, { needSats, haveSats } = {}) {
super(message);
this.name = "InsufficientFundsError";
this.code = "INSUFFICIENT_FUNDS";
this.needSats = needSats;
this.haveSats = haveSats;
}
}
// ---------- name + payload codec (must match bns.js / resolver-web.js) ----------
// The on-chain name: bare "<label>" for .bch (legacy compatibility), or
// "<label>.<tld>" for every other registry.
export function normalizeName(input) {
const parts = String(input).toLowerCase().trim().replace(/\.$/, "").split(".").filter(Boolean);
let label, tld;
if (parts.length === 0) throw new Error(`invalid name "${input}"`);
if (parts.length === 1) { label = parts[0]; tld = DEFAULT_TLD; }
else { tld = parts[parts.length - 1]; label = parts[parts.length - 2]; }
if (!LABEL_RE.test(label) || !TLD_RE.test(tld)) {
throw new Error(`invalid name "${input}" (allowed: <label>.<tld>, [a-z0-9-])`);
}
return tld === DEFAULT_TLD ? label : `${label}.${tld}`;
}
// Display form, always "<label>.<tld>".
export function displayName(rawName) {
return rawName.includes(".") ? rawName : `${rawName}.${DEFAULT_TLD}`;
}
export function encodePayload(op, rawName, records) {
const json = JSON.stringify({ o: op, n: rawName, r: records ?? {} });
const bytes = utf8ToBin(PREFIX + json);
if (bytes.length > MAX_PAYLOAD_BYTES) {
throw new Error(`records too large (${bytes.length} > ${MAX_PAYLOAD_BYTES} bytes)`);
}
return bytes;
}
// How many bytes of record payload are still available for a given name.
export function payloadBudget(rawName, op = "REG") {
return MAX_PAYLOAD_BYTES - encodePayload(op, rawName, {}).length;
}
// ---------- script helpers ----------
function pushData(data) {
if (data.length <= 0x4b) return Uint8Array.from([data.length, ...data]);
if (data.length <= 0xff) return Uint8Array.from([0x4c, data.length, ...data]);
return Uint8Array.from([0x4d, data.length & 0xff, (data.length >> 8) & 0xff, ...data]);
}
export function opReturnScript(payload) {
return Uint8Array.from([0x6a, ...pushData(payload)]);
}
// The placeholder an unsigned input carries. Matches cashscript's
// `placeholderP2PKHUnlocker` byte-for-byte so wallets size fees identically.
export function placeholderUnlocker() {
return Uint8Array.from([0x41, ...new Uint8Array(65), 0x21, ...new Uint8Array(33)]);
}
// ---------- input selection ----------
const isSpendableCoin = (u) => !u.token; // never spend a UTXO that holds a certificate
/**
* Choose inputs for a registration.
* Input 0 is always a token-free UTXO at vout 0 (the genesis input); the new
* certificate's category ID will be that UTXO's txid.
*/
export function selectInputs(utxos, targetSats, { requireGenesis = true } = {}) {
const coins = utxos.filter(isSpendableCoin);
const chosen = [];
let total = 0n;
if (requireGenesis) {
const genesisCandidates = coins.filter((u) => u.vout === 0).sort((a, b) => (b.satoshis > a.satoshis ? 1 : -1));
if (genesisCandidates.length === 0) {
throw new NoGenesisInputError(
"wallet has no coin at output index 0, which CashTokens requires to create a " +
"new certificate category — send a small amount to yourself first (buildPrepTx)",
);
}
chosen.push(genesisCandidates[0]);
total += genesisCandidates[0].satoshis;
}
// Then largest-first from the rest until the target is covered.
const rest = coins
.filter((u) => !chosen.some((c) => c.txid === u.txid && c.vout === u.vout))
.sort((a, b) => (b.satoshis > a.satoshis ? 1 : -1));
for (const u of rest) {
if (total >= targetSats) break;
chosen.push(u);
total += u.satoshis;
}
return { inputs: chosen, totalSats: total };
}
// ---------- transaction assembly ----------
function toInput(utxo) {
return {
outpointTransactionHash: hexToBin(utxo.txid), // libauth uses display byte order
outpointIndex: utxo.vout,
sequenceNumber: 0xfffffffe,
unlockingBytecode: placeholderUnlocker(),
};
}
// WC2 `sourceOutputs[i]` = the input, merged with the output it spends.
function toSourceOutput(utxo) {
return {
...toInput(utxo),
lockingBytecode: utxo.lockingBytecode,
valueSatoshis: utxo.satoshis,
...(utxo.token
? {
token: {
amount: utxo.token.amount ?? 0n,
category: hexToBin(utxo.token.category),
...(utxo.token.nft
? {
nft: {
capability: utxo.token.nft.capability,
commitment: hexToBin(utxo.token.nft.commitment || ""),
},
}
: {}),
},
}
: {}),
};
}
function measure(transaction) {
return encodeTransaction(transaction).length;
}
/**
* Build a BNS1 REG transaction: mints the name certificate to the buyer's own
* address, publishes the records, pays the discovery beacon, and (optionally)
* pays the registrar service fee.
*
* Two funding models, one builder:
*
* **Self-custody** — the buyer's own coins pay, and `changeAddress` defaults to
* `ownerAddress`, so change returns to them at vout 0 and their next
* registration inherits a genesis-eligible coin.
*
* **Registrar-minted (pay-by-QR)** — the registrar's coins pay, so pass
* `changeAddress` = the registrar's address while `ownerAddress` stays the
* buyer's. The certificate is minted straight to the buyer in the same
* transaction, so the registrar never holds the name for even one block; the
* change (and the genesis-input privilege) stays with the registrar.
*
* @param {object} o
* @param {string} o.name name as typed, e.g. "alice.p2p"
* @param {string} o.ownerAddress buyer's cashaddr — receives the certificate
* @param {string} [o.changeAddress] where change goes; defaults to `ownerAddress`.
* Set this to the funder when the funder is not the buyer.
* @param {object} [o.records] BNS1 record payload (u/h/ip/tls/a/...)
* @param {Array} o.utxos spendable UTXOs (see chain.js getUtxos)
* @param {string} [o.feeAddress] service-fee recipient
* @param {bigint} [o.feeSats] service fee in satoshis
* @param {string} [o.beaconAddress]
* @param {number} [o.feeRate] sat/byte, default 1
* @param {function} o.addressToLockingBytecode injected from chain.js
* @param {string} [o.ownerTokenAddress] token-aware form of ownerAddress
* @returns {{name, displayName, category, transaction, sourceOutputs, inputUtxos, costs, outputMap}}
*/
export function buildRegistrationTx({
name,
ownerAddress,
ownerTokenAddress,
changeAddress,
records = {},
utxos,
feeAddress = null,
feeSats = 0n,
beaconAddress = BEACON,
feeRate = 1,
addressToLockingBytecode,
}) {
if (typeof addressToLockingBytecode !== "function") {
throw new Error("addressToLockingBytecode must be injected (see chain.js)");
}
const rawName = normalizeName(name);
const payload = encodePayload("REG", rawName, records);
const serviceFee = BigInt(feeSats ?? 0n);
if (serviceFee > 0n && !feeAddress) throw new Error("feeSats set but feeAddress missing");
// Change goes to whoever funded the transaction — the buyer in the
// self-custody flow, the registrar when the registrar mints on their behalf.
const changeLock = addressToLockingBytecode(changeAddress ?? ownerAddress);
const certLock = addressToLockingBytecode(ownerTokenAddress ?? ownerAddress);
const beaconLock = addressToLockingBytecode(beaconAddress);
const feeLock = serviceFee > 0n ? addressToLockingBytecode(feeAddress) : null;
// Everything the transaction must pay out, before miner fee and change.
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT + serviceFee;
// Iterate: pick inputs, measure the real encoded size, re-check funding.
let selection = selectInputs(utxos, fixedOut + 300n); // 300 = first-pass fee guess
let chainFee = 0n;
let change = 0n;
let withChange = true;
for (let round = 0; round < 6; round++) {
const { inputs, totalSats } = selection;
const genesisUtxo = inputs[0];
const category = hexToBin(genesisUtxo.txid);
const build = (includeChange, changeSats) => {
const outputs = [];
// vout 0 = change, so the next registration inherits a genesis input.
if (includeChange) outputs.push({ lockingBytecode: changeLock, valueSatoshis: changeSats });
outputs.push({
lockingBytecode: certLock,
valueSatoshis: CERTIFICATE_DUST_SAT,
token: {
amount: 0n,
category,
nft: { capability: "none", commitment: utf8ToBin(rawName) },
},
});
outputs.push({ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n });
outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT });
if (feeLock) outputs.push({ lockingBytecode: feeLock, valueSatoshis: serviceFee });
return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs };
};
const probe = build(true, 1_000_000n); // realistic-width change for sizing
const size = measure(probe);
chainFee = BigInt(Math.ceil(size * feeRate));
const need = fixedOut + chainFee;
if (totalSats < need) {
const grown = selectInputs(utxos, need + 200n);
if (grown.inputs.length > inputs.length) { selection = grown; continue; }
throw new InsufficientFundsError(
`not enough funds: need ${need} sat, have ${totalSats} sat`,
{ needSats: need, haveSats: totalSats },
);
}
change = totalSats - need;
withChange = change >= P2PKH_DUST_SAT;
if (!withChange) {
// Change is dust: drop the output, re-measure, and let it go to the miner.
const noChange = build(false, 0n);
chainFee = totalSats - fixedOut;
const minFee = BigInt(Math.ceil(measure(noChange) * feeRate));
if (chainFee < minFee) {
const grown = selectInputs(utxos, fixedOut + minFee + P2PKH_DUST_SAT + 200n);
if (grown.inputs.length > inputs.length) { selection = grown; continue; }
throw new InsufficientFundsError(
`not enough funds: need ${fixedOut + minFee} sat, have ${totalSats} sat`,
{ needSats: fixedOut + minFee, haveSats: totalSats },
);
}
change = 0n;
}
const transaction = build(withChange, change);
const outputMap = {};
let i = 0;
if (withChange) outputMap.change = i++;
outputMap.certificate = i++;
outputMap.opReturn = i++;
outputMap.beacon = i++;
if (feeLock) outputMap.serviceFee = i++;
return {
name: rawName,
displayName: displayName(rawName),
category: binToHex(category),
transaction,
sourceOutputs: inputs.map(toSourceOutput),
inputUtxos: inputs,
outputMap,
payload,
costs: {
certificateDustSats: CERTIFICATE_DUST_SAT,
beaconDustSats: BEACON_DUST_SAT,
serviceFeeSats: serviceFee,
chainFeeSats: chainFee,
changeSats: change,
totalInputSats: totalSats,
// What actually leaves the buyer's control (the certificate dust comes back
// to them as the certificate itself, so it is not really "spent").
netCostSats: serviceFee + chainFee + BEACON_DUST_SAT,
},
};
}
throw new Error("could not converge on a fee — please retry");
}
/**
* Build a BNS1 UPD transaction: proves ownership by moving the name's
* certificate back to the owner, and republishes the records.
* @param {object} o.certificateUtxo the UTXO holding the certificate
*/
export function buildUpdateTx({
name,
ownerAddress,
ownerTokenAddress,
records = {},
certificateUtxo,
utxos,
beaconAddress = BEACON,
feeRate = 1,
addressToLockingBytecode,
}) {
if (typeof addressToLockingBytecode !== "function") {
throw new Error("addressToLockingBytecode must be injected (see chain.js)");
}
const rawName = normalizeName(name);
if (!certificateUtxo?.token?.nft) throw new Error("certificateUtxo does not carry a certificate");
const expected = binToHex(utf8ToBin(rawName));
if (certificateUtxo.token.nft.commitment !== expected) {
throw new Error(
`certificate commitment mismatch: holds "${binToUtf8(hexToBin(certificateUtxo.token.nft.commitment))}", ` +
`asked to update "${rawName}"`,
);
}
const payload = encodePayload("UPD", rawName, records);
const ownerLock = addressToLockingBytecode(ownerAddress);
const certLock = addressToLockingBytecode(ownerTokenAddress ?? ownerAddress);
const beaconLock = addressToLockingBytecode(beaconAddress);
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT;
// The certificate UTXO itself funds part of this; top up from plain coins.
let funding = selectInputs(utxos, fixedOut + 400n, { requireGenesis: false });
let chainFee = 0n;
let change = 0n;
let withChange = true;
for (let round = 0; round < 6; round++) {
const inputs = [certificateUtxo, ...funding.inputs];
const totalSats = certificateUtxo.satoshis + funding.totalSats;
const build = (includeChange, changeSats) => {
const outputs = [];
if (includeChange) outputs.push({ lockingBytecode: ownerLock, valueSatoshis: changeSats });
outputs.push({
lockingBytecode: certLock,
valueSatoshis: CERTIFICATE_DUST_SAT,
token: {
amount: 0n,
category: hexToBin(certificateUtxo.token.category),
nft: { capability: "none", commitment: utf8ToBin(rawName) },
},
});
outputs.push({ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n });
outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT });
return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs };
};
const size = measure(build(true, 1_000_000n));
chainFee = BigInt(Math.ceil(size * feeRate));
const need = fixedOut + chainFee;
if (totalSats < need) {
const grown = selectInputs(utxos, need + 200n, { requireGenesis: false });
if (grown.inputs.length > funding.inputs.length) { funding = grown; continue; }
throw new InsufficientFundsError(
`not enough funds: need ${need} sat, have ${totalSats} sat`,
{ needSats: need, haveSats: totalSats },
);
}
change = totalSats - need;
withChange = change >= P2PKH_DUST_SAT;
if (!withChange) { chainFee = totalSats - fixedOut; change = 0n; }
const transaction = build(withChange, change);
const outputMap = {};
let i = 0;
if (withChange) outputMap.change = i++;
outputMap.certificate = i++;
outputMap.opReturn = i++;
outputMap.beacon = i++;
return {
name: rawName,
displayName: displayName(rawName),
category: certificateUtxo.token.category,
transaction,
sourceOutputs: inputs.map(toSourceOutput),
inputUtxos: inputs,
outputMap,
payload,
costs: {
certificateDustSats: CERTIFICATE_DUST_SAT,
beaconDustSats: BEACON_DUST_SAT,
serviceFeeSats: 0n,
chainFeeSats: chainFee,
changeSats: change,
totalInputSats: totalSats,
netCostSats: chainFee + BEACON_DUST_SAT,
},
};
}
throw new Error("could not converge on a fee — please retry");
}
/**
* Build the one-off "prepare wallet" self-send used when a wallet holds no
* coin at vout 0. Output 0 goes back to the owner, which is genesis-eligible.
* Only ever needed once per wallet — registrations put their change at vout 0.
*/
export function buildPrepTx({
ownerAddress,
utxos,
amountSats = 10_000n,
feeRate = 1,
addressToLockingBytecode,
}) {
const ownerLock = addressToLockingBytecode(ownerAddress);
let selection = selectInputs(utxos, amountSats + 400n, { requireGenesis: false });
if (selection.inputs.length === 0) throw new InsufficientFundsError("wallet is empty");
for (let round = 0; round < 6; round++) {
const { inputs, totalSats } = selection;
const build = (includeChange, changeSats) => ({
version: 2,
locktime: 0,
inputs: inputs.map(toInput),
outputs: [
{ lockingBytecode: ownerLock, valueSatoshis: amountSats },
...(includeChange ? [{ lockingBytecode: ownerLock, valueSatoshis: changeSats }] : []),
],
});
const chainFee = BigInt(Math.ceil(measure(build(true, 1_000_000n)) * feeRate));
if (totalSats < amountSats + chainFee) {
// Not enough for the requested amount — sweep everything into output 0 instead.
const sweepFee = BigInt(Math.ceil(measure(build(false, 0n)) * feeRate));
if (totalSats <= sweepFee + P2PKH_DUST_SAT) {
throw new InsufficientFundsError(
`not enough funds to prepare the wallet: have ${totalSats} sat`,
{ needSats: sweepFee + P2PKH_DUST_SAT, haveSats: totalSats },
);
}
const swept = {
version: 2, locktime: 0, inputs: inputs.map(toInput),
outputs: [{ lockingBytecode: ownerLock, valueSatoshis: totalSats - sweepFee }],
};
return { transaction: swept, sourceOutputs: inputs.map(toSourceOutput), inputUtxos: inputs, costs: { chainFeeSats: sweepFee } };
}
const change = totalSats - amountSats - chainFee;
const withChange = change >= P2PKH_DUST_SAT;
return {
transaction: build(withChange, change),
sourceOutputs: inputs.map(toSourceOutput),
inputUtxos: inputs,
costs: { chainFeeSats: withChange ? chainFee : totalSats - amountSats },
};
}
throw new Error("could not converge on a fee — please retry");
}
/** Does this wallet already hold a coin that can start a new certificate? */
export function hasGenesisInput(utxos) {
return utxos.some((u) => u.vout === 0 && !u.token);
}
// ---------- WC2 payload ----------
/**
* The `bch_signTransaction` params object, shared verbatim by WalletConnect v2
* and WizardConnect (which wraps it and adds `inputPaths`).
* NOTE: callers must serialise this with libauth's `stringify`, never
* `JSON.stringify` — wallets validate the `<Uint8Array: 0x..>` / `<bigint: ..n>`
* markers strictly. See WalletConnect_integration_reference.md §5 / §9.3.
*/
export function toWcTransactionObject({ transaction, sourceOutputs }, { broadcast = true, userPrompt } = {}) {
return { transaction, sourceOutputs, broadcast, ...(userPrompt ? { userPrompt } : {}) };
}
export { encodeTransaction, decodeTransaction, binToHex, hexToBin };

496
src/lib/registrar.js Normal file
View file

@ -0,0 +1,496 @@
// The registration service: availability -> build -> sign -> broadcast -> verify.
//
// Transport-agnostic. The built-in wallet signs locally through `signTransaction`;
// WizardConnect hands the very same `{transaction, sourceOutputs}` to an external
// wallet instead. Everything up to the signature is shared, which is the point:
// the path that can be tested on chipnet is the path external wallets exercise too.
import { connectAny } from "./electrum.js";
import {
getUtxosForAddresses,
broadcast as broadcastTx,
addressToLockingBytecode,
toTokenAddress,
} from "./chain.js";
import {
buildRegistrationTx,
buildUpdateTx,
buildPrepTx,
hasGenesisInput,
normalizeName,
displayName,
payloadBudget,
toWcTransactionObject,
} from "./register-tx.js";
import { signTransaction, txidOfHex } from "./sign-tx.js";
import { buildIndex } from "./resolver-web.js";
import { REGISTRAR } from "../registrar-config.js";
/** Open a connection to the chain. Caller must `.close()` it. */
export async function connect({ electrum = REGISTRAR.electrum, WebSocket } = {}) {
return connectAny(electrum, WebSocket);
}
/** Is this name still free? Reads the chain, not a database. */
export async function checkAvailability(client, name) {
const raw = normalizeName(name);
const key = displayName(raw);
const index = await buildIndex({ client });
const entry = index.get(key) ?? null;
return {
name: key,
rawName: raw,
available: !entry,
entry,
recordBudgetBytes: payloadBudget(raw),
};
}
/**
* Quote a registration without signing anything: exact outputs, exact costs.
* This is what the confirmation screen shows, and what the WizardConnect path
* sends to an external wallet.
*/
export async function quoteRegistration(client, {
name,
ownerAddress,
records = {},
utxos,
serviceFee = REGISTRAR.serviceFee,
feeRate = REGISTRAR.feeRate,
}) {
const coins = utxos ?? (await getUtxosForAddresses(client, [ownerAddress]));
return buildRegistrationTx({
name,
ownerAddress,
ownerTokenAddress: toTokenAddress(ownerAddress),
records,
utxos: coins,
feeAddress: serviceFee.sats > 0n ? serviceFee.address : null,
feeSats: serviceFee.sats,
beaconAddress: REGISTRAR.beacon,
feeRate,
addressToLockingBytecode,
});
}
/**
* Register a name using the built-in wallet: the buyer's own key signs, and the
* certificate is minted to the buyer's own address. We never hold either.
*
* @param {object} o.wallet a BuiltInWallet
* @param {(step: string, detail?: object) => void} [o.onProgress]
*/
export async function registerWithBuiltInWallet(client, {
wallet,
name,
records = {},
serviceFee = REGISTRAR.serviceFee,
feeRate = REGISTRAR.feeRate,
onProgress = () => {},
}) {
onProgress("checking-availability");
const availability = await checkAvailability(client, name);
if (!availability.available) throw new Error(`"${availability.name}" is already registered`);
onProgress("loading-coins");
let utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
const spendable = utxos.filter((u) => !u.token).reduce((s, u) => s + u.satoshis, 0n);
if (spendable === 0n) throw new Error("wallet is empty — fund the address first");
// CashTokens can only create a category by spending an outpoint at index 0.
// A wallet that has never registered before may not have one; make it one.
let prepTxid = null;
if (!hasGenesisInput(utxos)) {
onProgress("preparing-wallet");
const prep = buildPrepTx({
ownerAddress: wallet.address,
utxos,
feeRate,
addressToLockingBytecode,
});
const signedPrep = signTransaction(prep.transaction, prep.sourceOutputs, (h) => wallet.keyFor(h));
prepTxid = await broadcastTx(client, signedPrep.hex);
onProgress("prepared", { txid: prepTxid });
// Spend the prep tx's output 0 directly rather than waiting for the indexer.
utxos = [
{
txid: prepTxid,
vout: 0,
satoshis: signedPrep.transaction.outputs[0].valueSatoshis,
address: wallet.address,
lockingBytecode: addressToLockingBytecode(wallet.address),
},
...(signedPrep.transaction.outputs.length > 1
? [{
txid: prepTxid,
vout: 1,
satoshis: signedPrep.transaction.outputs[1].valueSatoshis,
address: wallet.address,
lockingBytecode: addressToLockingBytecode(wallet.address),
}]
: []),
];
}
onProgress("building");
const built = await quoteRegistration(client, {
name,
ownerAddress: wallet.address,
records,
utxos,
serviceFee,
feeRate,
});
onProgress("signing");
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
onProgress("broadcasting");
const txid = await broadcastTx(client, signed.hex);
onProgress("registered", { txid, category: built.category });
return {
name: built.name,
displayName: built.displayName,
category: built.category,
txid,
prepTxid,
ownerAddress: wallet.address,
certificateAddress: toTokenAddress(wallet.address),
costs: built.costs,
hex: signed.hex,
};
}
/**
* Mint a name certificate on a buyer's behalf, from the registrar's own coins.
*
* This is the fulfilment half of the pay-by-QR flow: the buyer has already paid
* to their order's deposit address, so no service-fee output belongs in this
* transaction — charging again here would bill them twice.
*
* The certificate is locked to the buyer's address and the change returns to the
* registrar, so the name is never in a registrar-controlled output, not even for
* one block. That is the whole point: the buyer trusts us to mint, not to hold.
*
* @param {object} o.registrarWallet a wallet with `watchedAddresses`, `address`, `keyFor`
* @param {string} o.buyerTokenAddress where the certificate goes
*/
export async function mintForBuyer(client, {
registrarWallet,
name,
buyerTokenAddress,
records = {},
feeRate = REGISTRAR.feeRate,
onProgress = () => {},
}) {
onProgress("checking-availability");
const availability = await checkAvailability(client, name);
if (!availability.available) {
// The buyer has already paid at this point, so this must be loud and
// distinguishable — it is a refund case, not a retry case.
const err = new Error(`"${availability.name}" was registered by someone else before we could mint`);
err.code = "NAME_TAKEN";
throw err;
}
onProgress("loading-registrar-coins");
let utxos = await getUtxosForAddresses(client, registrarWallet.watchedAddresses);
const spendable = utxos.filter((u) => !u.token).reduce((s, u) => s + u.satoshis, 0n);
if (spendable === 0n) {
const err = new Error("registrar wallet is empty — cannot mint");
err.code = "REGISTRAR_EMPTY";
throw err;
}
// The registrar needs a vout-0 coin to create a category. After the first mint
// its own change sits at vout 0, so this only ever runs once per wallet.
if (!hasGenesisInput(utxos)) {
onProgress("preparing-registrar-wallet");
const prep = buildPrepTx({
ownerAddress: registrarWallet.address,
utxos,
feeRate,
addressToLockingBytecode,
});
const signedPrep = signTransaction(prep.transaction, prep.sourceOutputs, (h) => registrarWallet.keyFor(h));
const prepTxid = await broadcastTx(client, signedPrep.hex);
onProgress("registrar-prepared", { txid: prepTxid });
utxos = signedPrep.transaction.outputs.map((o, vout) => ({
txid: prepTxid,
vout,
satoshis: o.valueSatoshis,
address: registrarWallet.address,
lockingBytecode: addressToLockingBytecode(registrarWallet.address),
}));
}
onProgress("building");
const built = buildRegistrationTx({
name,
ownerAddress: buyerTokenAddress, // certificate destination
ownerTokenAddress: toTokenAddress(buyerTokenAddress),
changeAddress: registrarWallet.address, // our coins funded it, our change
records,
utxos,
feeAddress: null,
feeSats: 0n, // already paid via the deposit address
beaconAddress: REGISTRAR.beacon,
feeRate,
addressToLockingBytecode,
});
onProgress("signing");
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => registrarWallet.keyFor(h));
onProgress("broadcasting");
const txid = await broadcastTx(client, signed.hex);
onProgress("minted", { txid, category: built.category });
return {
name: built.name,
displayName: built.displayName,
category: built.category,
txid,
ownerAddress: buyerTokenAddress,
costs: built.costs,
};
}
/**
* Get a wallet-signed transaction onto the chain.
*
* Who broadcasts differs per transport, and getting it wrong is silent: hdwalletv1
* returns only the signed hex (no txid) and its SDK never broadcasts, whereas a
* WC2-style wallet honouring `broadcast: true` sends the tx itself. So we ask the
* session which it does, and treat "already in the mempool" as success rather
* than an error — a tx that is already there is exactly the outcome we wanted.
*/
async function landOnChain(client, session, signed, onProgress) {
const txid = signed.signedTransactionHash ?? txidOfHex(signed.signedTransaction);
if (session.walletBroadcasts) return txid;
onProgress("broadcasting");
try {
return await broadcastTx(client, signed.signedTransaction);
} catch (e) {
const msg = String(e?.message ?? e);
if (/already|duplicate|txn-already|in mempool/i.test(msg)) return txid; // someone beat us to it
throw e;
}
}
/**
* Register a name with an EXTERNAL wallet (WizardConnect).
*
* Identical transaction to the built-in path — same builder, same outputs, same
* consensus rules. The only difference is who holds the key: here the wallet
* signs on the user's own device and we never see a private key at all.
*
* The genesis-input rule is the one place external wallets need extra care. We
* cannot fabricate a vout-0 coin in someone else's wallet, so we scan what they
* expose and, only if nothing qualifies, ask for one extra "prepare" signature.
* See connect/wallet-session.js `describeGenesisRequirement`.
*
* @param {object} o.session a WalletSession (see connect/)
*/
export async function registerWithExternalWallet(client, {
session,
name,
ownerAddress,
records = {},
serviceFee = REGISTRAR.serviceFee,
feeRate = REGISTRAR.feeRate,
onProgress = () => {},
}) {
onProgress("checking-availability");
const availability = await checkAvailability(client, name);
if (!availability.available) throw new Error(`"${availability.name}" is already registered`);
onProgress("reading-wallet");
const addresses = await session.getAddresses();
if (!addresses.length) throw new Error("the wallet exposed no addresses");
// The certificate goes to the wallet's primary address unless told otherwise.
const owner = ownerAddress ?? addresses[0];
onProgress("loading-coins");
let utxos = await getUtxosForAddresses(client, addresses);
if (!utxos.some((u) => !u.token)) throw new Error("the connected wallet has no spendable coins");
if (!hasGenesisInput(utxos)) {
onProgress("preparing-wallet");
const prep = buildPrepTx({ ownerAddress: owner, utxos, feeRate, addressToLockingBytecode });
const prepSigned = await session.signTransaction(prep, {
userPrompt: "One-time setup so your wallet can create a name certificate",
});
const prepTxid = await landOnChain(client, session, prepSigned, onProgress);
onProgress("prepared", { txid: prepTxid });
utxos = [
{
txid: prepTxid,
vout: 0,
satoshis: prep.transaction.outputs[0].valueSatoshis,
address: owner,
lockingBytecode: addressToLockingBytecode(owner),
},
...utxos.filter((u) => !u.token && !prep.inputUtxos.some((i) => i.txid === u.txid && i.vout === u.vout)),
];
}
onProgress("building");
const built = await quoteRegistration(client, {
name,
ownerAddress: owner,
records,
utxos,
serviceFee,
feeRate,
});
onProgress("awaiting-approval");
const signed = await session.signTransaction(built, {
userPrompt: `Register ${built.displayName} — the certificate is minted to your own address`,
});
const txid = await landOnChain(client, session, signed, onProgress);
onProgress("registered", { txid, category: built.category });
return {
name: built.name,
displayName: built.displayName,
category: built.category,
txid,
ownerAddress: owner,
certificateAddress: toTokenAddress(owner),
costs: built.costs,
};
}
/** Find the UTXO that currently holds a given name's certificate. */
export async function findCertificateUtxo(client, { addresses, category, rawName }) {
const utxos = await getUtxosForAddresses(client, addresses);
const commitment = [...new TextEncoder().encode(rawName)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return (
utxos.find(
(u) => u.token?.category === category && u.token?.nft?.commitment === commitment,
) ?? null
);
}
/**
* Point a name somewhere: publishes new records by moving the certificate,
* which is the ownership proof the resolver checks.
*/
export async function setRecordsWithBuiltInWallet(client, {
wallet,
name,
records,
feeRate = REGISTRAR.feeRate,
onProgress = () => {},
}) {
onProgress("resolving");
const raw = normalizeName(name);
const index = await buildIndex({ client });
const entry = index.get(displayName(raw));
if (!entry) throw new Error(`"${displayName(raw)}" is not registered`);
onProgress("loading-coins");
const utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
const certificateUtxo = utxos.find(
(u) => u.token?.category === entry.category && u.token?.nft,
);
if (!certificateUtxo) {
throw new Error(
`this wallet does not hold the certificate for "${displayName(raw)}" — only the owner can change its records`,
);
}
onProgress("building");
const built = buildUpdateTx({
name,
ownerAddress: wallet.address,
ownerTokenAddress: toTokenAddress(wallet.address),
records,
certificateUtxo,
utxos: utxos.filter((u) => !u.token),
beaconAddress: REGISTRAR.beacon,
feeRate,
addressToLockingBytecode,
});
onProgress("signing");
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
onProgress("broadcasting");
const txid = await broadcastTx(client, signed.hex);
onProgress("updated", { txid });
return { name: built.name, displayName: built.displayName, category: built.category, txid, records, costs: built.costs };
}
/**
* Set records with an external wallet. Same update transaction as the built-in
* path; the wallet signs it. Needs no genesis input — an update spends the
* certificate it already owns rather than creating a category.
*/
export async function setRecordsWithExternalWallet(client, {
session,
name,
records,
feeRate = REGISTRAR.feeRate,
onProgress = () => {},
}) {
onProgress("resolving");
const raw = normalizeName(name);
const index = await buildIndex({ client });
const entry = index.get(displayName(raw));
if (!entry) throw new Error(`"${displayName(raw)}" is not registered`);
onProgress("reading-wallet");
const addresses = await session.getAddresses();
const utxos = await getUtxosForAddresses(client, addresses);
const certificateUtxo = utxos.find((u) => u.token?.category === entry.category && u.token?.nft);
if (!certificateUtxo) {
throw new Error(
`this wallet does not hold the certificate for "${displayName(raw)}" — only the owner can change its records`,
);
}
onProgress("building");
const built = buildUpdateTx({
name,
ownerAddress: certificateUtxo.address,
ownerTokenAddress: toTokenAddress(certificateUtxo.address),
records,
certificateUtxo,
utxos: utxos.filter((u) => !u.token),
beaconAddress: REGISTRAR.beacon,
feeRate,
addressToLockingBytecode,
});
onProgress("awaiting-approval");
const signed = await session.signTransaction(built, {
userPrompt: `Point ${built.displayName} somewhere new`,
});
const txid = await landOnChain(client, session, signed, onProgress);
onProgress("updated", { txid });
return { name: built.name, displayName: built.displayName, category: built.category, txid, records, costs: built.costs };
}
/** Total spendable (non-certificate) balance across the wallet's addresses. */
export async function getBalance(client, addresses) {
const utxos = await getUtxosForAddresses(client, addresses);
const spendable = utxos.filter((u) => !u.token);
return {
sats: spendable.reduce((s, u) => s + u.satoshis, 0n),
confirmedSats: spendable.filter((u) => u.height > 0).reduce((s, u) => s + u.satoshis, 0n),
coins: spendable.length,
certificates: utxos.filter((u) => u.token?.nft),
hasGenesisInput: hasGenesisInput(utxos),
};
}
export { toWcTransactionObject, normalizeName, displayName, REGISTRAR };

17
src/lib/resolve-web.js Normal file
View file

@ -0,0 +1,17 @@
// Node test/CLI for the portable resolver: injects `ws` as WebSocket.
// Usage: node src/lib/resolve-web.js [name]
import WebSocket from "ws";
import { buildIndex, resolveName } from "./resolver-web.js";
const [name] = process.argv.slice(2);
if (name) {
const e = await resolveName(name, { WebSocket });
console.log(e ?? `NXDOMAIN: ${name}`);
} else {
const index = await buildIndex({ WebSocket });
if (!index.size) console.log("(no names)");
for (const e of index.values()) {
console.log(`${e.name} -> ${JSON.stringify(e.records)} [category ${e.category.slice(0, 12)}…]`);
}
}
process.exit(0);

325
src/lib/resolver-web.js Normal file
View file

@ -0,0 +1,325 @@
// Portable BNS resolver — the shared keystone for all clients (browser
// extension, mobile webview, Electron). ZERO dependencies: only a WebSocket
// (native in browsers; inject Node's `ws`). Reads names straight from the
// Bitcoin Cash chain via a chipnet electrum (Fulcrum) server, using verbose
// transactions so CashToken data arrives as plain JSON — no tx-decoding lib.
//
// Contract (identical semantics to Argus/src/lib/bns.js):
// buildIndex() -> Map<name, Entry>
// resolveName("x.bch") -> Entry | null
// normalizeName(input) -> string (throws on invalid)
export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
// electrum scripthash of BEACON (sha256(lockingBytecode) reversed, hex).
// Constant — the beacon never changes.
export const BEACON_SCRIPTHASH =
"e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
export const PREFIX = "BNS1";
export const TLD = ".bch";
export const DEFAULT_TLD = "bch";
const LABEL_RE = /^[a-z0-9-]{1,32}$/; // the registered label
const TLD_RE = /^[a-z0-9-]{1,16}$/; // the TLD suffix
// an on-chain name: "<label>" (legacy = .bch) or "<label>.<tld>"
const RAWNAME_RE = /^[a-z0-9-]{1,32}(\.[a-z0-9-]{1,16})?$/;
// Canonical index key for an on-chain name: always "<label>.<tld>".
// Legacy bare names (no dot) are treated as .bch for backward compatibility.
function canonicalKey(raw) {
return raw.includes(".") ? raw : raw + "." + DEFAULT_TLD;
}
// Each server carries a hostname `url` (used everywhere; TLS "just works") and an
// optional pinned `ip`. DNS-independent clients (Theseus desktop, via directIP)
// dial the ip directly when the hostname can't be resolved — so a dead system
// resolver no longer stops Theseus from reaching the chain. The ip is only a
// 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 = [
{ 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" },
];
// A well-known BCNR name whose record carries the current electrum server pool.
// Resolved AFTER bootstrap (you need a server to read it) to refresh the list
// without shipping a new build — the decentralized, ICANN-free source of truth.
export const ELECTRUM_LIST_NAME = "electrum.silentmode.bch";
const hexToBytes = (h) => {
const a = new Uint8Array(h.length / 2);
for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16);
return a;
};
const strToHex = (s) =>
[...new TextEncoder().encode(s)].map((x) => x.toString(16).padStart(2, "0")).join("");
// Parse a full address ([<sub>.]<label>.<tld> or bare <label>) to the canonical
// registered-name key "<label>.<tld>". The rightmost label is the TLD (registry).
export function normalizeName(input) {
const s = String(input).toLowerCase().trim().replace(/^[a-z]+:\/\//, "").split("/")[0].replace(/\.$/, "");
const parts = s.split(".").filter(Boolean);
if (parts.length === 0) throw new Error(`invalid name "${input}"`);
let label, tld;
if (parts.length === 1) { label = parts[0]; tld = DEFAULT_TLD; }
else { tld = parts[parts.length - 1]; label = parts[parts.length - 2]; }
if (!LABEL_RE.test(label) || !TLD_RE.test(tld)) {
throw new Error(`invalid name "${input}" (allowed: <label>.<tld>, [a-z0-9-])`);
}
return `${label}.${tld}`;
}
// ---- electrum over a WebSocket (JSON-RPC; Fulcrum-WS framing tolerant) ----
class Electrum {
constructor(url, WebSocketImpl, options) {
this.url = url;
this.WS = WebSocketImpl || globalThis.WebSocket;
this.options = options; // Node `ws` options (e.g. { servername }); omitted in browsers
this.id = 0;
this.pending = new Map();
this.buf = "";
}
connect() {
return new Promise((resolve, reject) => {
// Only pass an options arg when we have one (Node `ws`). In a browser the
// 2nd arg is `protocols`, so we must never pass options there — the ip path
// that sets options is gated behind directIP, which browsers don't enable.
const ws = this.options ? new this.WS(this.url, this.options) : new this.WS(this.url);
this.ws = ws;
const onOpen = async () => {
try { await this.call("server.version", ["bns-web", "1.4.2"]); resolve(this); }
catch (e) { reject(e); }
};
const onErr = (e) => reject(new Error("electrum ws error: " + (e?.message || this.url)));
const onMsg = (ev) => this._onData(typeof ev.data === "string" ? ev.data : ev.data.toString());
if (ws.addEventListener) {
ws.addEventListener("open", onOpen);
ws.addEventListener("error", onErr);
ws.addEventListener("message", onMsg);
} else { ws.onopen = onOpen; ws.onerror = onErr; ws.onmessage = onMsg; }
});
}
_handle(msg) {
const p = this.pending.get(msg.id);
if (!p) return;
this.pending.delete(msg.id);
msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result);
}
_onData(chunk) {
this.buf += chunk;
let nl;
while ((nl = this.buf.indexOf("\n")) >= 0) {
const line = this.buf.slice(0, nl).trim();
this.buf = this.buf.slice(nl + 1);
if (line) { try { this._handle(JSON.parse(line)); } catch {} }
}
const rest = this.buf.trim();
if (rest) { try { const m = JSON.parse(rest); this._handle(m); this.buf = ""; } catch {} }
}
call(method, params = []) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }) + "\n");
setTimeout(() => {
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
}, 20000);
});
}
close() { try { this.ws.close(); } catch {} }
}
// A server is either a bare "wss://host:port" string or { url, ip }.
const asServer = (s) => (typeof s === "string" ? { url: s } : s);
async function connectAny(servers, WebSocketImpl, { directIP = false } = {}) {
let lastErr;
for (const raw of servers) {
const s = asServer(raw);
// 1) By hostname — works in every environment, needs working DNS.
try { return await new Electrum(s.url, WebSocketImpl).connect(); }
catch (e) { lastErr = e; }
// 2) DNS-independent fallback: dial the pinned IP directly, presenting the
// hostname as TLS SNI so the certificate still validates. Node/Electron
// only (browser WebSocket can't set SNI), enabled by directIP.
if (directIP && s.ip) {
try {
const u = new URL(s.url);
const ipUrl = `${u.protocol}//${s.ip}:${u.port}`;
return await new Electrum(ipUrl, WebSocketImpl, { servername: u.hostname }).connect();
} catch (e) { lastErr = e; }
}
}
throw lastErr ?? new Error("no electrum server reachable");
}
// ---- OP_RETURN payload parsing (from scriptPubKey hex) ----
function parseOpReturnHex(hex) {
if (!hex || !hex.startsWith("6a")) return null; // OP_RETURN
const s = hexToBytes(hex);
let i = 1;
const pushes = [];
while (i < s.length) {
let op = s[i++], len;
if (op <= 0x4b) len = op;
else if (op === 0x4c) len = s[i++];
else if (op === 0x4d) { len = s[i] | (s[i + 1] << 8); i += 2; }
else return null;
pushes.push(s.slice(i, i + len)); i += len;
}
const data = new TextDecoder().decode(pushes[0] ?? new Uint8Array());
if (!data.startsWith(PREFIX)) return null;
try {
const p = JSON.parse(data.slice(PREFIX.length));
if ((p.o === "REG" || p.o === "UPD") && typeof p.n === "string") return p;
} catch {}
return null;
}
// ---- ordering ----
// Baseline order: block height, then txid. Unconfirmed (height <= 0) last.
// Electrum reports height 0 for a mempool tx whose parents are all confirmed
// and -1 for one with unconfirmed parents; both are simply "not in a block".
function byHeightThenTxid(a, b) {
const ha = a.height > 0 ? a.height : Infinity;
const hb = b.height > 0 ? b.height : Infinity;
return ha - hb || a.tx_hash.localeCompare(b.tx_hash);
}
// Causal order. The (height, txid) baseline is arbitrary within a block and
// within the mempool, which breaks the most ordinary sequence there is:
// register a name and immediately point it somewhere. If the UPD's txid sorts
// below its REG's, the UPD is evaluated against an index that does not contain
// the name yet and is silently dropped — permanently, if both land in the same
// block. So: keep the baseline order, but never emit a transaction before one
// it spends from. Independent transactions keep the deterministic txid tiebreak,
// which is what "first valid REG wins" relies on.
//
// O(n^2) over the beacon history, which is small and read once per index build.
function orderByDependency(history, txs) {
const inSet = new Set(history.map((h) => h.tx_hash));
const deps = new Map(
history.map((h) => {
const spent = new Set();
for (const vin of txs.get(h.tx_hash)?.vin ?? []) {
if (vin.txid && vin.txid !== h.tx_hash && inSet.has(vin.txid)) spent.add(vin.txid);
}
return [h.tx_hash, spent];
}),
);
const emitted = new Set();
const ordered = [];
const pending = [...history]; // already in baseline order
while (pending.length) {
let i = pending.findIndex((h) => [...deps.get(h.tx_hash)].every((d) => emitted.has(d)));
if (i < 0) i = 0; // unreachable on a real chain; guarantees termination
const [next] = pending.splice(i, 1);
emitted.add(next.tx_hash);
ordered.push(next);
}
return ordered;
}
// ---- the resolver ----
// Pass `client` to reuse an already-open Electrum connection (the registrar
// does this); in that case the caller owns it and we leave it open.
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false } = {}) {
const el = client ?? (await connectAny(electrum, WS, { directIP }));
try {
// With a pool, take the UNION of every server's beacon history. A single
// server can silently omit a registration, and because "first valid REG
// wins" that makes a taken name look free — the buyer pays for a duplicate
// every other resolver rejects. Union, not quorum: omission is the attack,
// so more history is more truth, and every entry still has to pass the
// validity rules below before it can claim a name.
let integrity = null;
let history;
if (typeof el.unionHistory === "function") {
const merged = await el.unionHistory(BEACON_SCRIPTHASH);
history = merged.history;
integrity = {
servers: merged.servers,
unreachable: merged.unreachable,
disagreements: merged.disagreements,
};
} else {
history = await el.call("blockchain.scripthash.get_history", [BEACON_SCRIPTHASH]);
}
history.sort(byHeightThenTxid);
// Fetch first, then order. An UPD is only valid once its REG has been seen,
// so a transaction must never be indexed before one it spends from.
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); }
catch { /* unreadable tx — skip it entirely */ }
}
const names = new Map();
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
const tx = txs.get(tx_hash);
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
if (!payload) continue;
const rawName = payload.n;
if (!RAWNAME_RE.test(rawName)) continue;
const key = canonicalKey(rawName); // "<label>.<tld>"
if (payload.o === "REG") {
if (names.has(key)) continue; // first valid REG wins
const commitHex = strToHex(rawName); // commitment matches the raw on-chain name
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
names.set(key, {
name: key, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,
});
} else if (payload.o === "UPD") {
const entry = names.get(key);
if (!entry) continue;
const proves = vouts.some((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proves) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
}
}
// Attached rather than returned separately so every existing caller keeps
// working. A caller that cares (the registrar, before taking money) can ask
// whether the servers agreed on what exists.
if (integrity) Object.defineProperty(names, "integrity", { value: integrity, enumerable: false });
return names;
} finally {
if (!client) el.close();
}
}
export async function resolveName(input, opts = {}) {
const name = normalizeName(input);
const index = await buildIndex(opts);
return index.get(name) ?? null;
}
// ---- on-chain electrum discovery (step 2) --------------------------------
// The name ELECTRUM_LIST_NAME carries the current server pool in an "el" record:
// a space-separated list of tokens, each "wss://host:port" or "wss://host:port|ip"
// (the pinned ip is optional, and lets a discovered server also be DNS-independent).
// Read AFTER bootstrap and used to refresh the pool without shipping a new build.
export function parseElectrumRecord(records) {
const raw = records && records.el;
if (!raw || typeof raw !== "string") return [];
return raw.split(/\s+/).filter(Boolean).map((tok) => {
const [url, ip] = tok.split("|");
return ip ? { url, ip } : { url };
}).filter((s) => /^wss?:\/\//i.test(s.url));
}
// Pull the server pool out of an already-built index (no extra round-trip).
export function getElectrumServers(index) {
const entry = index && typeof index.get === "function" ? index.get(ELECTRUM_LIST_NAME) : null;
return entry ? parseElectrumRecord(entry.records) : [];
}
// Convenience: connect, read the on-chain list, return it (empty if unpublished).
export async function fetchElectrumServers(opts = {}) {
return getElectrumServers(await buildIndex(opts));
}

65
src/lib/sia-upload.js Normal file
View file

@ -0,0 +1,65 @@
// Upload a file (or directory) to Sia via the local s3d daemon.
// Usage: node src/lib/sia-upload.js <file-or-dir> <bucket[/prefix]>
// node src/lib/sia-upload.js sia-test.html bns
// node src/lib/sia-upload.js ../SomeSite/dist bns/somesite
import { readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import {
S3Client, CreateBucketCommand, PutObjectCommand, HeadBucketCommand,
} from "@aws-sdk/client-s3";
const MIME = {
html: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
json: "application/json", png: "image/png", jpg: "image/jpeg",
svg: "image/svg+xml", ico: "image/x-icon", txt: "text/plain",
wasm: "application/wasm", woff2: "font/woff2",
};
const [src, dest] = process.argv.slice(2);
if (!src || !dest) {
console.error("usage: node src/lib/sia-upload.js <file-or-dir> <bucket[/prefix]>");
process.exit(1);
}
const cfg = JSON.parse(readFileSync(new URL("../../sia-s3.json", import.meta.url), "utf8"));
const s3 = new S3Client({
endpoint: cfg.endpoint ?? "http://127.0.0.1:8000",
region: "us-east-1",
forcePathStyle: true,
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
});
const [bucket, ...prefixParts] = dest.split("/");
const prefix = prefixParts.length ? prefixParts.join("/") + "/" : "";
try {
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
console.log(`created bucket "${bucket}"`);
}
const files = [];
if (statSync(src).isDirectory()) {
const walk = (dir) => {
for (const f of readdirSync(dir)) {
const p = join(dir, f);
statSync(p).isDirectory() ? walk(p) : files.push([p, prefix + relative(src, p).replaceAll("\\", "/")]);
}
};
walk(src);
} else {
files.push([src, prefix + basename(src)]);
}
for (const [path, key] of files) {
const ext = key.split(".").at(-1)?.toLowerCase();
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: readFileSync(path),
ContentType: MIME[ext] ?? "application/octet-stream",
}));
console.log(`uploaded ${path} -> s3://${bucket}/${key}`);
}
console.log(`\nrecord value for the chain: {"s3":"${bucket}/${files.length > 1 ? prefix : files[0][1]}"}`);
process.exit(0);

149
src/lib/sign-message.js Normal file
View file

@ -0,0 +1,149 @@
// Proof that a buyer controls the address a name is about to be minted to.
//
// WHY THIS GATES THE MINT
// A name certificate is irrevocable. Minting to an address the buyer does not
// control destroys it — and the most common way that happens is an exchange
// withdrawal, where the payer has no key for the sending address at all. Asking
// them to type a destination does not help: a typo is indistinguishable from a
// correct answer until it is too late.
//
// So the mint is gated on a signature. The buyer signs a challenge with the key
// for the destination address; we recover the public key from the signature and
// check it hashes to that address. Someone paying from an exchange simply cannot
// produce this, which is the point — they get a refund instead of a lost name.
//
// ORDERING MATTERS AND IS EASY TO GET BACKWARDS. The signature must come BEFORE
// the mint. "Sign to confirm you received it, else refund" is exploitable: the
// name is already minted and irrevocable, so a buyer who never signs keeps the
// name AND gets the money back.
//
// FORMAT: BIP-137, the "Bitcoin Signed Message" scheme every BCH wallet
// implements (Electron Cash, Deviant, Cashonize, Electrum). We verify what real
// wallets actually produce rather than inventing a scheme only we can sign.
import {
sha256,
hash160,
secp256k1,
utf8ToBin,
binToHex,
base64ToBin,
lockingBytecodeToCashAddress,
cashAddressToLockingBytecode,
} from "@bitauth/libauth";
const MAGIC = "Bitcoin Signed Message:\n";
/** Bitcoin's length-prefixed string encoding. */
function varStr(bytes) {
const n = bytes.length;
let prefix;
if (n < 0xfd) prefix = Uint8Array.of(n);
else if (n <= 0xffff) prefix = Uint8Array.of(0xfd, n & 0xff, (n >> 8) & 0xff);
else prefix = Uint8Array.of(0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff);
return Uint8Array.from([...prefix, ...bytes]);
}
/** The digest a wallet actually signs: double-SHA256 over magic || message. */
export function messageMagicHash(message) {
const payload = Uint8Array.from([
...varStr(utf8ToBin(MAGIC)),
...varStr(utf8ToBin(String(message))),
]);
return sha256.hash(sha256.hash(payload));
}
/**
* Verify a BIP-137 signature and return the address that produced it.
*
* The signature is 65 bytes base64: [header][r|s], where the header encodes both
* the recovery id and whether the public key was compressed. We recover the key
* and hash it — so the caller learns *which* address signed, and compares that
* to the one they expected, rather than trusting a claim.
*
* @returns {{address: string, publicKey: Uint8Array, compressed: boolean} | null}
*/
export function recoverSigner(message, signatureBase64, prefix = "bchtest") {
let sig;
try {
sig = base64ToBin(String(signatureBase64).trim());
} catch {
return null;
}
if (!(sig instanceof Uint8Array) || sig.length !== 65) return null;
const header = sig[0];
// 27..30 uncompressed, 31..34 compressed. Some wallets emit 39..42 for
// segwit variants, which do not apply to BCH — reject rather than guess.
if (header < 27 || header > 34) return null;
const compressed = header >= 31;
const recoveryId = (header - 27) & 0b11;
const compact = sig.slice(1);
const digest = messageMagicHash(message);
const recover = compressed
? secp256k1.recoverPublicKeyCompressed
: secp256k1.recoverPublicKeyUncompressed;
const publicKey = recover(compact, recoveryId, digest);
if (typeof publicKey === "string") return null; // libauth reports errors as strings
const r = lockingBytecodeToCashAddress({
bytecode: Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(publicKey), 0x88, 0xac]),
prefix,
tokenSupport: false,
});
if (typeof r === "string") return null;
return { address: r.address, publicKey, compressed };
}
/**
* Did the holder of `expectedAddress` sign this message?
*
* Compares locking bytecode rather than address strings, so a token-aware `z…`
* address and its `q…` form are correctly treated as the same key — a buyer
* signing with a wallet that only shows the legacy form must still pass.
*/
export function verifyControl({ message, signature, expectedAddress, prefix = "bchtest" }) {
const signer = recoverSigner(message, signature, prefix);
if (!signer) return { ok: false, reason: "signature is malformed or not a Bitcoin message signature" };
let expected, actual;
try {
expected = cashAddressToLockingBytecode(expectedAddress);
actual = cashAddressToLockingBytecode(signer.address);
} catch {
return { ok: false, reason: "address could not be decoded" };
}
if (typeof expected === "string" || typeof actual === "string") {
return { ok: false, reason: "address could not be decoded" };
}
if (binToHex(expected.bytecode) !== binToHex(actual.bytecode)) {
return {
ok: false,
reason: "signed by a different key than the destination address",
signedBy: signer.address,
};
}
return { ok: true, signedBy: signer.address, publicKey: signer.publicKey };
}
/**
* The exact text a buyer is asked to sign.
*
* Bound to the name, the destination and the order so a signature harvested from
* one purchase cannot be replayed against another, and readable enough that
* someone can tell what they are approving in their wallet's signing dialog.
*/
export function controlChallenge({ name, destination, orderIndex }) {
return `Silent Mode: mint ${name} to ${destination} (order ${orderIndex})`;
}
/** Sign a challenge locally — the built-in wallet's side of the same scheme. */
export function signMessageBip137(message, privateKey, compressed = true) {
const digest = messageMagicHash(message);
const result = secp256k1.signMessageHashRecoverableCompact(privateKey, digest);
if (typeof result === "string") throw new Error(`signing failed: ${result}`);
const header = 27 + result.recoveryId + (compressed ? 4 : 0);
const sig = Uint8Array.from([header, ...result.signature]);
return btoa(String.fromCharCode(...sig));
}

70
src/lib/sign-tx.js Normal file
View file

@ -0,0 +1,70 @@
// Local P2PKH signing for transactions produced by register-tx.js.
// Used by the built-in wallet. External wallets (WalletConnect v2 /
// WizardConnect) do this step themselves on the user's device — this module is
// never given their keys.
import {
generateSigningSerializationBCH,
hash256,
secp256k1,
encodeTransaction,
binToHex,
} from "@bitauth/libauth";
// SIGHASH_ALL | SIGHASH_FORKID
export const SIGHASH_ALL_FORKID = 0x41;
/**
* Fill in the unlocking bytecode for every input this signer owns.
*
* @param {object} transaction libauth Transaction (placeholders in inputs)
* @param {Array} sourceOutputs the outputs being spent (from register-tx.js)
* @param {(lockingBytecodeHex: string) => ({privateKey: Uint8Array, publicKey: Uint8Array}|null)} keyFor
* @returns {{ transaction, hex, txid }}
*/
export function signTransaction(transaction, sourceOutputs, keyFor) {
const signed = {
...transaction,
inputs: transaction.inputs.map((input, inputIndex) => {
const source = sourceOutputs[inputIndex];
const key = keyFor(binToHex(source.lockingBytecode));
if (!key) throw new Error(`no key for input ${inputIndex} — cannot sign`);
const serialization = generateSigningSerializationBCH(
{ inputIndex, sourceOutputs, transaction },
{
coveredBytecode: source.lockingBytecode,
signingSerializationType: Uint8Array.of(SIGHASH_ALL_FORKID),
},
);
const digest = hash256(serialization);
const signature = secp256k1.signMessageHashSchnorr(key.privateKey, digest);
if (typeof signature === "string") throw new Error(`signing failed on input ${inputIndex}: ${signature}`);
const sig = Uint8Array.from([...signature, SIGHASH_ALL_FORKID]);
return {
...input,
unlockingBytecode: Uint8Array.from([sig.length, ...sig, key.publicKey.length, ...key.publicKey]),
};
}),
};
const encoded = encodeTransaction(signed);
return { transaction: signed, hex: binToHex(encoded), txid: txidOf(encoded) };
}
/** Display-order txid (what block explorers and electrum show). */
export function txidOf(encodedTransaction) {
return binToHex(hash256(encodedTransaction).slice().reverse());
}
/**
* Same, from a raw transaction hex. Needed because WizardConnect's
* `sign_transaction_response` returns only the signed hex — no txid — so the
* dApp derives it itself.
*/
export function txidOfHex(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
return txidOf(bytes);
}

84
src/lib/tlds.js Normal file
View file

@ -0,0 +1,84 @@
// The TLDs this installation will mint TLS certificates for.
//
// The BNS protocol is deliberately TLD-agnostic: bns.js accepts any
// /^[a-z0-9-]{1,16}$/ as a TLD, because the chain decides what exists, not this
// file. TLS is the one place that cannot stay open-ended. The local root CA is
// installed into the machine trust store, and an X.509 name-constrained CA needs
// a *finite* list of permitted dNSName subtrees (RFC 5280 §4.2.1.10). Without
// one the root can vouch for every domain on the internet.
//
// So: resolution stays unbounded, certificate issuance is enumerated. A name on
// a TLD that is not listed here still resolves and still serves over plain HTTP
// — it just gets no certificate from the local CA.
//
// Adding a TLD requires regenerating the root CA and re-importing it on every
// machine (see setup/install.ps1). That cost is the point: it is what stops this
// root from being a general-purpose signing oracle.
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
// RFC 2606 reserves `.test` forever, and none of the others are ICANN-delegated.
export const DEFAULT_TLDS = ["bch", "p2p", "bit", "deviant", "nav", "test", "bcnr"];
// A tripwire, not a complete list — ICANN has ~1500 delegated TLDs and adds more.
// Its job is to make the obvious mistake loud, not to be authoritative. Anything
// added to the permitted list must be checked against the real IANA root zone:
// https://data.iana.org/TLD/tlds-alpha-by-domain.txt
const ICANN_TRIPWIRE = new Set([
"com", "net", "org", "edu", "gov", "mil", "int", "info", "biz", "io", "co",
"app", "dev", "xyz", "online", "site", "cloud", "ai", "me", "tv", "sh", "gg",
"uk", "us", "de", "fr", "nl", "eu", "ru", "cn", "jp", "ca", "au", "ch", "it",
]);
const TLD_RE = /^[a-z0-9-]{1,16}$/;
const CONFIG_FILE = fileURLToPath(new URL("../../ariadne.config.json", import.meta.url));
function validate(tlds) {
const out = [];
for (const raw of tlds) {
const tld = String(raw).trim().toLowerCase().replace(/^\.+/, "");
if (!TLD_RE.test(tld)) throw new Error(`invalid TLD "${raw}" in permitted list (allowed: [a-z0-9-]{1,16})`);
if (ICANN_TRIPWIRE.has(tld)) {
throw new Error(
`refusing to permit ".${tld}" — it is an ICANN-delegated TLD. Adding it would let the ` +
`local root CA issue trusted certificates for real internet domains on every machine ` +
`that installed it. If this is genuinely intended, edit ICANN_TRIPWIRE in src/lib/tlds.js.`,
);
}
if (!out.includes(tld)) out.push(tld);
}
if (!out.length) throw new Error("permitted TLD list is empty — the CA could issue nothing");
return out;
}
// Effective list = ariadne.config.json "tlds", else the built-in default.
// Read once at import: the root CA is baked against this list, so changing it at
// runtime would silently desync the constraints from the certificates.
export const PERMITTED_TLDS = (() => {
try {
if (existsSync(CONFIG_FILE)) {
const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
if (Array.isArray(cfg.tlds) && cfg.tlds.length) return validate(cfg.tlds);
}
} catch (e) {
if (e.message.startsWith("refusing") || e.message.startsWith("invalid TLD")) throw e;
console.error(`tlds: ${CONFIG_FILE} unreadable (${e.message}) — using defaults`);
}
return validate(DEFAULT_TLDS);
})();
// Rightmost label of a hostname, minus any port or trailing dot. null if unusable.
export function hostTld(host) {
if (typeof host !== "string" || !host) return null;
const bare = host.trim().toLowerCase().replace(/\.$/, "").replace(/:\d+$/, "");
if (!bare || bare.includes("/")) return null;
const tld = bare.slice(bare.lastIndexOf(".") + 1);
return TLD_RE.test(tld) ? tld : null;
}
// May the local CA issue a certificate for this hostname?
export function isPermittedHost(host, tlds = PERMITTED_TLDS) {
const tld = hostTld(host);
return tld !== null && tlds.includes(tld);
}

188
src/lib/wallet-web.js Normal file
View file

@ -0,0 +1,188 @@
// The built-in, in-browser, self-custody wallet for the registration flow.
//
// This exists so a newcomer with no BCH wallet at all can still buy a name.
// The seed phrase is generated in the buyer's browser, encrypted with their
// password (PBKDF2 -> AES-GCM via WebCrypto) and kept in localStorage. It is
// never transmitted anywhere. There is no reset: whoever holds the phrase holds
// the names. The UI must say so before the phrase is generated.
//
// Runs in the browser and in Node 18+ (both provide WebCrypto + TextEncoder).
import {
generateBip39Mnemonic,
deriveSeedFromBip39Mnemonic,
deriveHdPrivateNodeFromSeed,
deriveHdPath,
secp256k1,
hash160,
encodeCashAddress,
lockingBytecodeToCashAddress,
cashAddressToLockingBytecode,
binToHex,
hexToBin,
utf8ToBin,
binToBase64,
base64ToBin,
} from "@bitauth/libauth";
export const CHIPNET_PREFIX = "bchtest";
export const MAINNET_PREFIX = "bitcoincash";
// BCH coin type 145; account 0. Branch 0 = receive, 1 = change.
export const ACCOUNT_PATH = "m/44'/145'/0'";
// How many receiving addresses to watch. The flow funds and changes back to
// index 0, so this window only matters for coins sent in from elsewhere.
export const ADDRESS_WINDOW = 5;
const STORAGE_KEY = "bns.wallet.v1";
function p2pkhLockingBytecode(publicKey) {
const pkh = hash160(publicKey);
return Uint8Array.from([0x76, 0xa9, 0x14, ...pkh, 0x88, 0xac]);
}
function encodeAddress(publicKey, prefix, tokenSupport) {
const r = lockingBytecodeToCashAddress({
bytecode: p2pkhLockingBytecode(publicKey),
prefix,
tokenSupport,
});
if (typeof r === "string") throw new Error(`address encoding failed: ${r}`);
return r.address;
}
export class BuiltInWallet {
/** @param {string} mnemonic @param {string} prefix */
constructor(mnemonic, seed, prefix = CHIPNET_PREFIX) {
this.mnemonic = mnemonic;
this.prefix = prefix;
this._root = deriveHdPrivateNodeFromSeed(seed);
this._keys = new Map(); // lockingBytecodeHex -> {privateKey, publicKey, path}
this._derive();
}
static async create(prefix = CHIPNET_PREFIX) {
const mnemonic = generateBip39Mnemonic();
return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix);
}
static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX) {
const clean = String(mnemonic).trim().toLowerCase().replace(/\s+/g, " ");
const seed = await deriveSeedFromBip39Mnemonic(clean);
if (typeof seed === "string") throw new Error(`invalid recovery phrase: ${seed}`);
return new BuiltInWallet(clean, seed, prefix);
}
_derive() {
this.receive = [];
this.change = [];
for (const [branch, list] of [[0, this.receive], [1, this.change]]) {
for (let i = 0; i < ADDRESS_WINDOW; i++) {
const path = `${ACCOUNT_PATH}/${branch}/${i}`;
const node = deriveHdPath(this._root, path);
if (typeof node === "string") throw new Error(`HD derivation failed at ${path}: ${node}`);
const publicKey = secp256k1.derivePublicKeyCompressed(node.privateKey);
if (typeof publicKey === "string") throw new Error(`pubkey derivation failed: ${publicKey}`);
const lockingBytecode = p2pkhLockingBytecode(publicKey);
const entry = {
path,
branch,
index: i,
privateKey: node.privateKey,
publicKey,
lockingBytecode,
address: encodeAddress(publicKey, this.prefix, false),
tokenAddress: encodeAddress(publicKey, this.prefix, true),
};
list.push(entry);
this._keys.set(binToHex(lockingBytecode), entry);
}
}
}
/** The address the buyer funds and receives change on. */
get address() { return this.receive[0].address; }
/** Token-aware form — where the name certificate is minted to. */
get tokenAddress() { return this.receive[0].tokenAddress; }
/** Every address worth scanning for coins. */
get watchedAddresses() {
return [...this.receive, ...this.change].map((e) => e.address);
}
/** Signer callback for sign-tx.js. */
keyFor(lockingBytecodeHex) {
const e = this._keys.get(lockingBytecodeHex);
return e ? { privateKey: e.privateKey, publicKey: e.publicKey } : null;
}
// ---------- encrypted storage ----------
async encrypt(password) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, utf8ToBin(this.mnemonic)),
);
return JSON.stringify({
v: 1,
kdf: "PBKDF2-SHA256",
iterations: PBKDF2_ITERATIONS,
prefix: this.prefix,
salt: binToBase64(salt),
iv: binToBase64(iv),
ct: binToBase64(ct),
});
}
static async decrypt(blob, password) {
const o = typeof blob === "string" ? JSON.parse(blob) : blob;
const key = await deriveKey(password, base64ToBin(o.salt), o.iterations ?? PBKDF2_ITERATIONS);
let plain;
try {
plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBin(o.iv) },
key,
base64ToBin(o.ct),
);
} catch {
throw new Error("wrong password");
}
return BuiltInWallet.fromMnemonic(new TextDecoder().decode(plain), o.prefix ?? CHIPNET_PREFIX);
}
async save(password, storage = globalThis.localStorage) {
storage.setItem(STORAGE_KEY, await this.encrypt(password));
}
static async load(password, storage = globalThis.localStorage) {
const blob = storage.getItem(STORAGE_KEY);
if (!blob) return null;
return BuiltInWallet.decrypt(blob, password);
}
static exists(storage = globalThis.localStorage) {
return !!storage?.getItem(STORAGE_KEY);
}
static forget(storage = globalThis.localStorage) {
storage.removeItem(STORAGE_KEY);
}
}
const PBKDF2_ITERATIONS = 250_000;
async function deriveKey(password, salt, iterations = PBKDF2_ITERATIONS) {
const material = await crypto.subtle.importKey("raw", utf8ToBin(String(password)), "PBKDF2", false, [
"deriveKey",
]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export { encodeCashAddress, cashAddressToLockingBytecode, binToHex, hexToBin };

10
src/lib/wallet.js Normal file
View file

@ -0,0 +1,10 @@
import "./dns-patch.js"; // must load before mainnet-js touches the network
import { TestNetWallet } from "mainnet-js";
import { readFileSync } from "fs";
import { WALLET_FILE } from "../config.js";
export async function loadWallet(which = "main") {
const store = JSON.parse(readFileSync(WALLET_FILE, "utf8"));
const { seed, path } = store[which];
return TestNetWallet.fromSeed(seed, path);
}

18
src/register.js Normal file
View file

@ -0,0 +1,18 @@
// Usage: node src/register.js <name> <url-or-json>
// node src/register.js hello https://example.com
// node src/register.js hello '{"h":"<h1>Hi from the chain</h1>"}'
import { loadWallet } from "./lib/wallet.js";
import { registerName } from "./lib/bns.js";
const [name, data] = process.argv.slice(2);
if (!name || !data) {
console.error("usage: node src/register.js <name> <url-or-json>");
process.exit(1);
}
const records = data.trim().startsWith("{") ? JSON.parse(data) : { u: data };
const wallet = await loadWallet();
console.log(`registering "${name}" from ${wallet.cashaddr} ...`);
const res = await registerName(wallet, name, records);
console.log("registered:", res);
process.exit(0);

89
src/registrar-config.js Normal file
View file

@ -0,0 +1,89 @@
// Registrar configuration — the only place prices, the fee address and the
// network live. Everything here is public information; no secrets (SECURITY.md
// rule 0). This file is bundled into the public register page as-is.
import { CHIPNET_ELECTRUM } from "./lib/electrum.js";
export const REGISTRAR = {
// ---- network -----------------------------------------------------------
// CHIPNET ONLY. Mainnet is a separate, deliberate switch: it needs mainnet
// electrum servers, the "bitcoincash:" prefix, a mainnet beacon address and
// real money. Do not flip this without re-reading REGISTRATION-WALLET-PROMPT.md.
network: "chipnet",
addressPrefix: "bchtest",
// ---- chain connectivity -------------------------------------------------
// Order matters: the pool connects in parallel but prefers earlier entries,
// and takes the UNION of beacon history across everything that answers.
//
// PUT YOUR OWN FULCRUM FIRST, ON 443. Every public server below listens on a
// non-standard port (50004 / 62002), so a single firewall or VPN rule blocks
// all of them at once — observed 2026-07-27, when all three went TCP-closed
// while :443 stayed reachable. A self-hosted server on 443 is worth more than
// three more public ones, and it is not a centralisation trade: the public
// servers stay in the list as cross-checks, and the union means your own
// server cannot hide a registration from you either.
//
// selfHosted: "wss://bns.example.st:443",
//
// See OPERATIONS-electrum.md for running one.
selfHosted: null,
get electrum() {
return this.selfHosted ? [this.selfHosted, ...CHIPNET_ELECTRUM] : CHIPNET_ELECTRUM;
},
// ---- discovery ---------------------------------------------------------
beacon: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl",
// ---- price -------------------------------------------------------------
// The service fee is a plain output inside the buyer's own minting
// transaction — the registrar model, like ENS. We never take custody: the
// certificate is minted straight to the buyer's key in the same transaction
// that pays us. If `sats` is 0n the output is omitted entirely.
serviceFee: {
address: "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu",
sats: 10_000n, // chipnet placeholder price — set real pricing at mainnet
},
// Miner fee rate, satoshis per byte. 1 sat/byte confirms fine on BCH.
feeRate: 1,
// ---- external wallets ---------------------------------------------------
// WizardConnect only. WalletConnect v2 was removed deliberately (2026-07-27):
// it needs a Reown project ID and a relay we do not control, and WizardConnect
// already covers the same wallets (Cashonize 0.9+, Paytaca) over an
// end-to-end-encrypted Nostr transport with no third-party project registration.
//
// CashConnect is NOT here on purpose either — see the note in
// Decentralized.DNS/REGISTRATION-STATUS.md. Its V0 model has the wallet build
// the transaction and auto-select its own inputs, which cannot mint a name
// certificate: a CashTokens genesis is defined by one specific outpoint the
// dApp must choose, and the category ID is that outpoint's txid.
wizardConnect: {
dappName: "Silent Mode",
dappIcon: "https://silentmode.st/icon.png",
},
// Faucets shown to a buyer whose new wallet is empty (chipnet only).
faucets: [
{ name: "tbch.googol.cash", url: "https://tbch.googol.cash/" },
{ name: "chipnet.bch.ninja faucet", url: "https://chipnet.bch.ninja/faucet" },
],
};
/** Human-readable price breakdown for the confirmation screen. */
export function priceSummary(costs) {
const bch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
return [
{ label: "Service fee", sats: costs.serviceFeeSats, bch: bch(costs.serviceFeeSats) },
{ label: "Network fee", sats: costs.chainFeeSats, bch: bch(costs.chainFeeSats) },
{ label: "Discovery beacon", sats: costs.beaconDustSats, bch: bch(costs.beaconDustSats) },
{
label: "Held in the certificate",
sats: costs.certificateDustSats,
bch: bch(costs.certificateDustSats),
note: "stays yours — it lives in the certificate you now own",
},
{ label: "Total", sats: costs.netCostSats, bch: bch(costs.netCostSats), total: true },
];
}

18
src/resolve.js Normal file
View file

@ -0,0 +1,18 @@
// Usage: node src/resolve.js [name] — resolves one name, or lists all.
import { loadWallet } from "./lib/wallet.js";
import { buildIndex, resolveName } from "./lib/bns.js";
const [name] = process.argv.slice(2);
const wallet = await loadWallet();
if (name) {
const entry = await resolveName(wallet.provider, name);
console.log(entry ?? `NXDOMAIN: ${name}`);
} else {
const index = await buildIndex(wallet.provider);
if (index.size === 0) console.log("(no names registered yet)");
for (const e of index.values()) {
console.log(`${e.name}.bch -> ${JSON.stringify(e.records)} [category ${e.category.slice(0, 12)}…]`);
}
}
process.exit(0);

15
src/update.js Normal file
View file

@ -0,0 +1,15 @@
// Usage: node src/update.js <name> <url-or-json>
import { loadWallet } from "./lib/wallet.js";
import { updateName } from "./lib/bns.js";
const [name, data] = process.argv.slice(2);
if (!name || !data) {
console.error("usage: node src/update.js <name> <url-or-json>");
process.exit(1);
}
const records = data.trim().startsWith("{") ? JSON.parse(data) : { u: data };
const wallet = await loadWallet();
const res = await updateName(wallet, name, records);
console.log("updated:", res);
process.exit(0);

165
src/wallet-app.js Normal file
View file

@ -0,0 +1,165 @@
// BNS wallet: a wallet app with an embedded .bch resolver and browser pane.
// This is the "resolver inside the wallet" architecture: the wallet holds keys,
// resolves names straight from the BCH chain, and renders the result in-app.
import express from "express";
import { loadWallet } from "./lib/wallet.js";
import { buildIndex, resolveName, registerName, updateName, normalizeName } from "./lib/bns.js";
import { APP_PORT, TLD } from "./config.js";
const wallet = await loadWallet();
const app = express();
app.use(express.json());
// tiny index cache so browsing doesn't re-scan the beacon on every keystroke
let cache = { at: 0, index: new Map() };
async function index() {
if (Date.now() - cache.at > 15_000) {
cache = { at: Date.now(), index: await buildIndex(wallet.provider) };
}
return cache.index;
}
app.get("/api/status", async (_req, res) => {
res.json({
cashaddr: wallet.cashaddr,
tokenaddr: wallet.tokenaddr,
balanceSat: String(await wallet.getBalance("sat")),
names: [...(await index()).values()].map((e) => ({ ...e })),
});
});
app.get("/api/resolve/:name", async (req, res) => {
try {
const entry = (await index()).get(normalizeName(req.params.name));
if (!entry) return res.status(404).json({ error: "NXDOMAIN" });
res.json(entry);
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post("/api/register", async (req, res) => {
try {
const { name, records } = req.body;
cache.at = 0;
res.json(await registerName(wallet, name, records));
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post("/api/update", async (req, res) => {
try {
const { name, records } = req.body;
cache.at = 0;
res.json(await updateName(wallet, name, records));
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// The "browser" endpoint: renders what a .bch name points to.
app.get("/view/:name", async (req, res) => {
let entry;
try {
entry = (await index()).get(normalizeName(req.params.name));
} catch (e) {
return res.status(400).send(`<pre>${e.message}</pre>`);
}
if (!entry) {
return res.status(404).send(
`<div style="font-family:system-ui;padding:2rem"><h2>NXDOMAIN</h2>
<p><code>${req.params.name}</code> is not registered on the chain.</p></div>`);
}
if (entry.records.h) return res.send(entry.records.h); // on-chain website
if (entry.records.u) return res.redirect(entry.records.u);
res.send(`<pre style="padding:2rem">${JSON.stringify(entry, null, 2)}</pre>`);
});
app.get("/", (_req, res) => {
res.send(UI);
});
const UI = /* html */ `<!doctype html>
<meta charset="utf8">
<title>BNS Wallet — .bch browser</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; display: grid; grid-template-columns: 320px 1fr; height: 100vh; }
aside { border-right: 1px solid #8884; padding: 14px; overflow-y: auto; }
main { display: flex; flex-direction: column; }
h1 { font-size: 1.05rem; margin: 0 0 10px; }
h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .6; margin: 18px 0 6px; }
code, .addr { font-family: ui-monospace, monospace; font-size: .72rem; word-break: break-all; }
.bar { display: flex; gap: 8px; padding: 10px; border-bottom: 1px solid #8884; }
.bar input { flex: 1; padding: 8px 12px; border-radius: 999px; border: 1px solid #8886; font-size: .95rem; }
button { padding: 7px 14px; border-radius: 8px; border: 1px solid #8886; cursor: pointer; background: #4b7bec; color: #fff; }
button.ghost { background: transparent; color: inherit; }
iframe { flex: 1; border: 0; width: 100%; }
.name { padding: 6px 8px; border: 1px solid #8884; border-radius: 8px; margin: 4px 0; cursor: pointer; }
.name:hover { background: #4b7bec22; }
input, textarea { width: 100%; box-sizing: border-box; margin: 3px 0; padding: 6px; border-radius: 6px; border: 1px solid #8886; background: transparent; color: inherit; }
#status { font-size: .8rem; opacity: .75; padding: 4px 12px; border-bottom: 1px solid #8884; min-height: 1.2em; }
</style>
<aside>
<h1>⛓️ BNS Wallet</h1>
<h2>Wallet</h2>
<div class="addr" id="addr">…</div>
<div>Balance: <b id="bal">…</b> sat</div>
<h2>Registered names (on-chain)</h2>
<div id="names">loading…</div>
<h2>Register a name</h2>
<input id="rname" placeholder="name (a-z, 0-9, -)">
<input id="rurl" placeholder="https:// URL (optional)">
<textarea id="rhtml" rows="3" placeholder="…or inline HTML stored on-chain"></textarea>
<button onclick="registerName()">Register on BCH</button>
</aside>
<main>
<div class="bar">
<button class="ghost" onclick="go()">⟳</button>
<input id="url" placeholder="name${TLD} — resolved from the BCH chain, not DNS" onkeydown="if(event.key==='Enter')go()">
<button onclick="go()">Go</button>
</div>
<div id="status"></div>
<iframe id="page" src="about:blank"></iframe>
</main>
<script>
const $ = (id) => document.getElementById(id);
async function refresh() {
const s = await (await fetch("/api/status")).json();
$("addr").textContent = s.cashaddr;
$("bal").textContent = s.balanceSat;
$("names").innerHTML = s.names.length
? s.names.map(n => \`<div class="name" onclick="open2('\${n.name}')"><b>\${n.name}${TLD}</b><br><code>\${JSON.stringify(n.records)}</code></div>\`).join("")
: "<i>none yet</i>";
}
function open2(name) { $("url").value = name + "${TLD}"; go(); }
async function go() {
const q = $("url").value.trim(); if (!q) return;
$("status").textContent = "resolving " + q + " via BCH chain…";
const r = await fetch("/api/resolve/" + encodeURIComponent(q));
if (!r.ok) { $("status").textContent = q + " → NXDOMAIN (not on chain)"; $("page").src = "/view/" + encodeURIComponent(q); return; }
const e = await r.json();
$("status").textContent = q + " → " + (e.records.u ?? (e.records.h ? "on-chain HTML, " + e.records.h.length + " bytes" : JSON.stringify(e.records)))
+ " [certificate category " + e.category.slice(0, 16) + "…, reg tx " + e.txid.slice(0, 16) + "…]";
$("page").src = "/view/" + encodeURIComponent(q);
}
async function registerName() {
const records = {};
if ($("rurl").value.trim()) records.u = $("rurl").value.trim();
if ($("rhtml").value.trim()) records.h = $("rhtml").value.trim();
if (!Object.keys(records).length) { alert("give a URL or inline HTML"); return; }
$("status").textContent = "broadcasting registration…";
const r = await fetch("/api/register", { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ name: $("rname").value.trim(), records }) });
const j = await r.json();
$("status").textContent = r.ok ? "registered! txid " + j.txid : "error: " + j.error;
refresh();
}
refresh(); setInterval(refresh, 20000);
</script>`;
app.listen(APP_PORT, () => {
console.log(`BNS wallet+browser: http://localhost:${APP_PORT}`);
console.log(`wallet: ${wallet.cashaddr}`);
});

77
src/web/libauth-entry.js Normal file
View file

@ -0,0 +1,77 @@
// A single shared libauth for the whole page.
//
// WHY THIS FILE EXISTS
// libauth initialises its WASM crypto (secp256k1, sha256, ripemd160, …) with a
// TOP-LEVEL AWAIT. If two bundles each carry their own copy, each has its own
// unsettled init, and esbuild's inlined dynamic imports can hand out a bundle's
// exports before that init completes. Symptom, observed on cold load:
//
// TypeError: Cannot read properties of undefined
// (reading 'derivePublicKeyCompressed')
// at deriveNostrPublicKey (wizardconnect.js)
// at generateKeyExchangeCredentials
// at initiateDappRelay
//
// — failing 65 ms in, then "working" on every retry once the WASM had warmed.
// A bug that disappears when you look at it twice is exactly the kind that
// ships.
//
// So libauth becomes a real, separate ES module that every other bundle imports
// by bare specifier, resolved through the import map in register.html. The
// browser's module loader guarantees a dependency's top-level await settles
// before any importer evaluates — the ordering we need — and the page downloads
// libauth once instead of three times.
//
// WHY THE EXPORTS ARE LISTED BY HAND
// `export *` would defeat tree-shaking and ship all ~1 MB of libauth. This list
// is the union of what our own modules use and what @wizardconnect/core and
// /dapp import (they resolve to this module too, via the import map). Missing a
// name would be a runtime failure in a path we cannot unit-test, so
// build-web.mjs verifies this list against the imports every other bundle
// actually emits, and fails the build if anything is absent. Add freely; the
// check will tell you if you remove something still in use.
export {
// ---- used by @wizardconnect/core and @wizardconnect/dapp ----
bech32PaddedToBin,
binToBech32Padded,
decodeHdPublicKey,
deriveHdPublicNodeChild,
generatePrivateKey,
hash256,
secp256k1,
// ---- encoding ----
binToHex,
hexToBin,
utf8ToBin,
binToUtf8,
binToBase64,
base64ToBin,
// The WalletConnect / WizardConnect wire format: "<Uint8Array: 0x..>" and
// "<bigint: ..n>". Wallets validate these strictly.
stringify,
// ---- addresses ----
cashAddressToLockingBytecode,
lockingBytecodeToCashAddress,
encodeCashAddress,
decodeCashAddress,
// ---- hashing ----
sha256,
hash160,
ripemd160,
// ---- transactions ----
encodeTransaction,
decodeTransaction,
generateSigningSerializationBCH,
// ---- keys / HD wallets (the built-in wallet) ----
generateBip39Mnemonic,
deriveSeedFromBip39Mnemonic,
deriveHdPrivateNodeFromSeed,
deriveHdPath,
deriveHdPublicNode,
} from "@bitauth/libauth";

47
src/web/register-entry.js Normal file
View file

@ -0,0 +1,47 @@
// Browser entry point for the registration flow. Everything the register page
// needs, and nothing that touches the filesystem or a private key we hold.
//
// Bundled by build-web.mjs to site/js/bns-register.js as a plain ES module:
// <script type="module"> import * as BNS from './js/bns-register.js' </script>
export { BuiltInWallet, CHIPNET_PREFIX } from "../lib/wallet-web.js";
export {
connect,
checkAvailability,
quoteRegistration,
registerWithBuiltInWallet,
registerWithExternalWallet,
setRecordsWithBuiltInWallet,
setRecordsWithExternalWallet,
findCertificateUtxo,
getBalance,
} from "../lib/registrar.js";
export { REGISTRAR, priceSummary } from "../registrar-config.js";
export {
buildRegistrationTx,
buildUpdateTx,
buildPrepTx,
hasGenesisInput,
toWcTransactionObject,
normalizeName,
displayName,
payloadBudget,
NoGenesisInputError,
InsufficientFundsError,
} from "../lib/register-tx.js";
export { signTransaction, txidOf, txidOfHex } from "../lib/sign-tx.js";
export { toTokenAddress, addressToLockingBytecode, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js";
export { resolveName, buildIndex } from "../lib/resolver-web.js";
export {
describeGenesisRequirement,
pubkeyToAddress,
pubkeyToLockingBytecode,
} from "../lib/connect/wallet-session.js";
export { binToHex, hexToBin } from "@bitauth/libauth";
export { stringify } from "@bitauth/libauth";
// NOT re-exported here on purpose: the WizardConnect and WalletConnect adapters.
// They are separate bundles (site/js/wizardconnect.js, site/js/walletconnect2.js)
// that the page dynamic-imports only when the buyer picks that option.
// - WizardConnect: LGPL-3.0-or-later, must stay separate + unminified (§4d1).
// - WalletConnect: ~590 kB nobody using the built-in wallet should download.

198
test/chipnet-acceptance.mjs Normal file
View file

@ -0,0 +1,198 @@
// PHASE 1 ACCEPTANCE TEST — live, on chipnet, with real (worthless) coins.
//
// Proves the whole newcomer path end to end:
// fresh browser wallet -> funded -> registers <name>.bch -> registers
// <name>.p2p -> both certificates are held by the BUYER's key -> both names
// resolve from the chain -> records can be updated by the owner.
//
// The funding step (and only that step) uses the project's chipnet wallet via
// mainnet-js. That is deliberate: mainnet-js is an entirely independent
// implementation, so if our hand-built transactions were malformed the chain
// would reject them while mainnet-js's succeeded. Everything after funding is
// built, signed and broadcast by the code that ships to buyers.
//
// Run: node test/chipnet-acceptance.mjs
// Add --keep to print the throwaway wallet's phrase for reuse.
import WebSocket from "ws";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import {
connect,
checkAvailability,
registerWithBuiltInWallet,
setRecordsWithBuiltInWallet,
getBalance,
findCertificateUtxo,
} from "../src/lib/registrar.js";
import { REGISTRAR, priceSummary } from "../src/registrar-config.js";
import { resolveName } from "../src/lib/resolver-web.js";
import { loadWallet } from "../src/lib/wallet.js";
const FUND_SATS = 120_000;
const tag = Math.random().toString(16).slice(2, 8);
const NAME_BCH = `smtest-${tag}`;
const NAME_P2P = `smtest-${tag}.p2p`;
const log = (...a) => console.log(...a);
const step = (n, s) => log(`\n─── ${n}. ${s} ${"─".repeat(Math.max(0, 58 - s.length))}`);
const sats = (v) => `${v} sat (${(Number(v) / 1e8).toFixed(8)} tBCH)`;
let failures = 0;
function check(ok, label, detail = "") {
log(` ${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
if (!ok) failures++;
return ok;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(label, fn, { tries = 20, delay = 3000 } = {}) {
for (let i = 0; i < tries; i++) {
const v = await fn();
if (v) return v;
process.stdout.write(` waiting for ${label}… ${i + 1}/${tries}\r`);
await sleep(delay);
}
return null;
}
let client;
try {
client = await connect({ WebSocket });
} catch (e) {
console.error(`
cannot reach the chain — the test cannot run.
${e.message}
`);
process.exit(2); // 2 = environment, not a product failure
}
try {
log(`BNS registration — Phase 1 acceptance test on ${REGISTRAR.network}`);
log(`Service fee configured: ${sats(REGISTRAR.serviceFee.sats)} -> ${REGISTRAR.serviceFee.address}`);
// ── 1. a brand-new buyer, exactly as a newcomer would get ────────────────
step(1, "Create a fresh in-browser wallet (the buyer)");
const buyer = await BuiltInWallet.create();
log(` address : ${buyer.address}`);
log(` token address : ${buyer.tokenAddress}`);
if (process.argv.includes("--keep")) log(` phrase : ${buyer.mnemonic}`);
check((await getBalance(client, buyer.watchedAddresses)).sats === 0n, "new wallet starts empty");
// ── 2. fund it (stands in for the buyer funding from an exchange/faucet) ──
step(2, "Fund the buyer from the project chipnet wallet");
const funder = await loadWallet("main");
const fundResult = await funder.send([{ cashaddr: buyer.address, value: FUND_SATS, unit: "sat" }]);
log(` funding txid : ${fundResult.txId}`);
const funded = await waitFor("the coins to arrive", async () => {
const b = await getBalance(client, buyer.watchedAddresses);
return b.sats > 0n ? b : null;
});
log("");
check(!!funded && funded.sats === BigInt(FUND_SATS), "buyer received the funds", funded ? sats(funded.sats) : "none");
log(` wallet holds a vout-0 coin already: ${funded?.hasGenesisInput}`
+ (funded?.hasGenesisInput ? "" : " -> the prepare-wallet step will run"));
// ── 3. availability + quote ──────────────────────────────────────────────
step(3, `Check availability of ${NAME_BCH}.bch`);
const avail = await checkAvailability(client, NAME_BCH);
check(avail.available, `${avail.name} is available`);
check(avail.recordBudgetBytes > 50, "records have room in the payload", `${avail.recordBudgetBytes} bytes free`);
// ── 4. register a .bch name ──────────────────────────────────────────────
step(4, `Register ${NAME_BCH}.bch (legacy bare-label form)`);
const reg1 = await registerWithBuiltInWallet(client, {
wallet: buyer,
name: NAME_BCH,
records: { u: "https://silentmode.st" },
onProgress: (s, d) => log(` · ${s}${d ? " " + JSON.stringify(d) : ""}`),
});
log(` txid : ${reg1.txid}`);
log(` category : ${reg1.category}`);
for (const line of priceSummary(reg1.costs)) {
log(` ${line.total ? "TOTAL" : " "} ${line.label.padEnd(26)} ${String(line.sats).padStart(8)} sat${line.note ? " (" + line.note + ")" : ""}`);
}
check(reg1.category === reg1.hex.slice(0, 0) + reg1.category, "category recorded");
check(!!reg1.prepTxid === !funded?.hasGenesisInput, "prepare-wallet step ran only when needed");
// ── 5. register a multi-TLD name — no prep needed this time ──────────────
step(5, `Register ${NAME_P2P} (multi-TLD, straight after — no preparation)`);
const reg2 = await registerWithBuiltInWallet(client, {
wallet: buyer,
name: NAME_P2P,
records: { ip: "203.0.113.9" },
onProgress: (s, d) => log(` · ${s}${d ? " " + JSON.stringify(d) : ""}`),
});
log(` txid : ${reg2.txid}`);
check(
reg2.prepTxid === null,
"second registration needed NO preparation tx (change-at-vout-0 works)",
);
check(reg2.category !== reg1.category, "each certificate has its own category");
// ── 6. self-custody: the buyer holds both certificates ───────────────────
step(6, "Verify self-custody — the certificates are the buyer's, not ours");
const cert1 = await waitFor("certificate 1", () =>
findCertificateUtxo(client, { addresses: buyer.watchedAddresses, category: reg1.category, rawName: reg1.name }));
const cert2 = await waitFor("certificate 2", () =>
findCertificateUtxo(client, { addresses: buyer.watchedAddresses, category: reg2.category, rawName: reg2.name }));
log("");
check(!!cert1, `${reg1.displayName} certificate sits at the buyer's address`, cert1?.address);
check(!!cert2, `${reg2.displayName} certificate sits at the buyer's address`, cert2?.address);
const projectHolds = await findCertificateUtxo(client, {
addresses: [REGISTRAR.serviceFee.address],
category: reg1.category,
rawName: reg1.name,
});
check(projectHolds === null, "the registrar holds NO claim on the name");
// ── 7. the names actually resolve from the chain ─────────────────────────
step(7, "Resolve both names using the shipped zero-dependency resolver");
const r1 = await waitFor(`${reg1.displayName} to resolve`, () =>
resolveName(reg1.displayName, { WebSocket }));
const r2 = await waitFor(`${reg2.displayName} to resolve`, () =>
resolveName(reg2.displayName, { WebSocket }));
log("");
check(!!r1, `${reg1.displayName} resolves`, r1 ? JSON.stringify(r1.records) : "");
check(r1?.records?.u === "https://silentmode.st", "its record survived the round trip");
check(!!r2, `${reg2.displayName} resolves`, r2 ? JSON.stringify(r2.records) : "");
check(r2?.records?.ip === "203.0.113.9", "its record survived the round trip");
check(r1?.category === reg1.category, "resolver reports the category we minted");
// ── 8. the owner — and only the owner — can repoint the name ─────────────
step(8, `Set a hosting record on ${reg2.displayName} (Phase 4 update tx)`);
const upd = await setRecordsWithBuiltInWallet(client, {
wallet: buyer,
name: reg2.displayName,
records: { h: "<h1>hello from the chain</h1>" },
onProgress: (s, d) => log(` · ${s}${d ? " " + JSON.stringify(d) : ""}`),
});
log(` txid : ${upd.txid}`);
const r2b = await waitFor("the update to take effect", async () => {
const e = await resolveName(reg2.displayName, { WebSocket });
return e?.records?.h ? e : null;
});
log("");
check(!!r2b, "the update is live on the chain", r2b ? JSON.stringify(r2b.records) : "");
check(r2b?.category === reg2.category, "the name kept its identity across the update");
// ── 9. the storefront's own availability API agrees ──────────────────────
step(9, "Cross-check against the public availability API (navigate.st)");
try {
const api = await fetch(`https://navigate.st/api/name/${encodeURIComponent(reg1.displayName)}`)
.then((r) => r.json());
check(api.registered === true, "navigate.st reports the name as registered", JSON.stringify(api.records ?? {}));
} catch (e) {
log(` SKIP availability API unreachable (${e.message})`);
}
step("=", `RESULT: ${failures === 0 ? "ALL CHECKS PASSED" : failures + " CHECK(S) FAILED"}`);
log(` names registered: ${reg1.displayName}, ${reg2.displayName}`);
log(` owner address : ${buyer.address}`);
const end = await getBalance(client, buyer.watchedAddresses);
log(` buyer left with : ${sats(end.sats)} and ${end.certificates.length} certificate(s)`);
} finally {
client.close();
}
process.exit(failures === 0 ? 0 : 1);

View file

@ -0,0 +1,208 @@
// LIVE chipnet test: pay from an exchange, fail to sign, get refunded.
//
// THE SCENARIO
// 1. The buyer starts a purchase. We generate their destination wallet in the
// browser there and then, so the destination is always a key they hold.
// 2. They pay from somewhere else entirely — an exchange withdrawal, which
// cannot sign for anything and cannot receive a CashToken.
// 3. Before minting, they must sign a challenge with the destination key,
// proving they still have the wallet we gave them.
// 4. They never sign — the tab was closed, the phrase was never written down.
// 5. The quote expires and the escrow refunds the payment.
//
// WHAT THIS MUST PROVE
// - `magicworld.x` is NEVER minted. An irrevocable certificate locked to a key
// nobody holds is the worst outcome in the whole product, worse than a failed
// sale, and it is silent.
// - The money goes back to whoever actually paid, recovered from the payment
// transaction rather than from anything the buyer typed.
// - A signature from the wrong key does not open the gate.
//
// Run: node test/chipnet-escrow-refund.mjs
import WebSocket from "ws";
import { readFileSync } from "node:fs";
import { deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic } from "@bitauth/libauth";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { connect, checkAvailability, getBalance } from "../src/lib/registrar.js";
import { loadWallet } from "../src/lib/wallet.js";
import { payerAddressOf, addressToLockingBytecode, broadcast, getUtxos } from "../src/lib/chain.js";
import { signTransaction } from "../src/lib/sign-tx.js";
import { controlChallenge, verifyControl, signMessageBip137 } from "../src/lib/sign-message.js";
import {
quotePrice, bip21, createOrder, depositAddressAt, depositKeyAt,
checkPayment, buildRefundTx, ORDER_STATUS,
} from "../src/lib/orders.js";
import { REGISTRAR } from "../src/registrar-config.js";
const NAME = "magicworld.x";
let failures = 0;
const check = (ok, label, detail = "") => {
console.log(` ${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
if (!ok) failures++;
};
const step = (n, s) => console.log(`\n─── ${n}. ${s} ${"─".repeat(Math.max(0, 54 - s.length))}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let client;
try {
client = await connect({ WebSocket });
} catch (e) {
console.error(`\ncannot reach the chain — the test cannot run.\n${e.message}\n`);
process.exit(2);
}
try {
console.log(`Escrow refund on failure-to-sign — live on ${REGISTRAR.network}`);
// ── 1. destination wallet, created for the buyer during checkout ─────────
step(1, "Generate the buyer's destination wallet in-browser");
const buyer = await BuiltInWallet.create();
console.log(` destination : ${buyer.address}`);
check(
(await getBalance(client, buyer.watchedAddresses)).sats === 0n,
"brand-new wallet, usable immediately — the buyer could fund it right now",
);
const avail = await checkAvailability(client, NAME);
check(avail.available, `${NAME} is available before we start`);
if (!avail.available) throw new Error(`${NAME} is already registered — pick another name for this test`);
// ── 2. quote ─────────────────────────────────────────────────────────────
step(2, "Quote the name and open the escrow");
const store = JSON.parse(readFileSync(new URL("../wallets.json", import.meta.url), "utf8"));
const root = deriveHdPrivateNodeFromSeed(await deriveSeedFromBip39Mnemonic(store.main.seed));
const orderIndex = Math.floor(Math.random() * 1_000_000);
const deposit = depositAddressAt(orderIndex, { hdNode: root });
const priceSats = quotePrice({ serviceFeeSats: REGISTRAR.serviceFee.sats });
const order = createOrder({
index: orderIndex, name: NAME, buyerAddress: buyer.address,
priceSats, depositAddress: deposit, now: Date.now(),
});
console.log(` escrow addr : ${deposit}`);
console.log(` price : ${priceSats} sat`);
console.log(` QR : ${bip21({ address: deposit, satoshis: priceSats, message: `Register ${NAME}` })}`);
check(order.status === ORDER_STATUS.quoted, "order starts quoted");
// ── 3. payment from an "exchange" ────────────────────────────────────────
step(3, "Buyer pays from an exchange (cannot sign, cannot hold tokens)");
const exchange = await loadWallet("main");
const payment = await exchange.send([{ cashaddr: deposit, value: Number(priceSats), unit: "sat" }]);
console.log(` payment txid : ${payment.txId}`);
let paid = null;
for (let i = 0; i < 25; i++) {
const p = await checkPayment(client, order);
if (p.paid) { paid = p; break; }
process.stdout.write(` watching escrow… ${i + 1}/25\r`);
await sleep(3000);
}
console.log("");
check(!!paid, "escrow received the payment", paid ? `${paid.receivedSats} sat` : "nothing");
order.status = ORDER_STATUS.awaitingSignature;
// ── 4. the gate ──────────────────────────────────────────────────────────
step(4, "Gate the mint on proof the buyer still holds the destination key");
const challenge = controlChallenge({ name: NAME, destination: buyer.address, orderIndex });
console.log(` challenge : ${challenge}`);
// The buyer could sign — the key is right there in their browser. Prove the
// gate opens for the right key, so a later failure is meaningful.
const goodSig = signMessageBip137(challenge, buyer.receive[0].privateKey);
check(
verifyControl({ message: challenge, signature: goodSig, expectedAddress: buyer.address }).ok,
"the real destination key opens the gate",
);
// Someone else's key must not.
const impostor = await BuiltInWallet.create();
const badSig = signMessageBip137(challenge, impostor.receive[0].privateKey);
const badResult = verifyControl({ message: challenge, signature: badSig, expectedAddress: buyer.address });
check(!badResult.ok, "a different key does NOT open the gate", badResult.reason);
// Neither does a signature for a different order.
const replay = signMessageBip137(
controlChallenge({ name: NAME, destination: buyer.address, orderIndex: orderIndex + 1 }),
buyer.receive[0].privateKey,
);
check(
!verifyControl({ message: challenge, signature: replay, expectedAddress: buyer.address }).ok,
"a signature from another order cannot be replayed",
);
// ── 5. …but this buyer never signs ───────────────────────────────────────
step(5, "The buyer never signs — tab closed, phrase never saved");
const submitted = null; // nothing arrives before the deadline
const gateOpen = submitted
? verifyControl({ message: challenge, signature: submitted, expectedAddress: buyer.address }).ok
: false;
check(!gateOpen, "the mint gate stays shut");
// ── 6. expire and refund ─────────────────────────────────────────────────
step(6, "Quote expires — escrow refunds the payer");
order.expiresAt = Date.now() - 1; // deadline reached
check(Date.now() > order.expiresAt, "quote has expired");
order.status = ORDER_STATUS.refunding;
// Where does the money go? Read it off the payment, not off anything typed.
const payerAddress = await payerAddressOf(client, payment.txId);
console.log(` payer (recovered from the payment): ${payerAddress}`);
check(!!payerAddress, "payer address recovered from the payment transaction");
check(
payerAddress === exchange.cashaddr,
"recovered address matches the actual sender",
`expected ${exchange.cashaddr}`,
);
const depositKey = depositKeyAt(orderIndex, root);
const escrowUtxos = await getUtxos(client, deposit);
const refund = buildRefundTx({
utxos: escrowUtxos,
toAddress: payerAddress,
addressToLockingBytecode,
});
const signedRefund = signTransaction(refund.transaction, refund.sourceOutputs, (h) =>
h === Buffer.from(depositKey.lockingBytecode).toString("hex")
? { privateKey: depositKey.privateKey, publicKey: depositKey.publicKey }
: null,
);
const refundTxid = await broadcast(client, signedRefund.hex);
order.status = ORDER_STATUS.refunded;
console.log(` refund txid : ${refundTxid}`);
console.log(` refunded : ${refund.costs.refundedSats} sat (fee ${refund.costs.chainFeeSats})`);
let emptied = false;
for (let i = 0; i < 25; i++) {
const p = await checkPayment(client, order);
if (p.receivedSats === 0n) { emptied = true; break; }
process.stdout.write(` waiting for the escrow to drain… ${i + 1}/25\r`);
await sleep(3000);
}
console.log("");
check(emptied, "escrow is empty — the money left");
check(
refund.costs.refundedSats > priceSats - 1000n,
"buyer got back essentially everything",
`${refund.costs.refundedSats} of ${priceSats} sat`,
);
// ── 7. the thing that must never have happened ───────────────────────────
step(7, "Verify the name was never minted");
const after = await checkAvailability(client, NAME);
check(after.available, `${NAME} is STILL unregistered — no certificate was created`);
check(
(await getBalance(client, buyer.watchedAddresses)).certificates.length === 0,
"the buyer's wallet holds no certificate",
);
step("=", failures === 0 ? "RESULT: ALL CHECKS PASSED" : `RESULT: ${failures} CHECK(S) FAILED`);
console.log(` unsigned purchase refunded to ${payerAddress}`);
console.log(` ${NAME} never left the shelf`);
} finally {
client.close();
}
process.exit(failures === 0 ? 0 : 1);

188
test/chipnet-paybyqr.mjs Normal file
View file

@ -0,0 +1,188 @@
// LIVE chipnet check for the pay-by-QR fulfilment path.
//
// The claim under test is the one the whole trust story rests on: the registrar
// funds and signs the minting transaction, yet the certificate lands in a wallet
// the registrar has no key for, in that same transaction — so there is no moment
// at which the registrar could walk off with the name.
//
// The "buyer" here is a throwaway wallet that is never funded. That is the point:
// in this flow a buyer needs no coins in the destination wallet, no connector,
// and no CashTokens support beyond being able to receive.
//
// Run: node test/chipnet-paybyqr.mjs
import WebSocket from "ws";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { connect, mintForBuyer, findCertificateUtxo, getBalance } from "../src/lib/registrar.js";
import { resolveName } from "../src/lib/resolver-web.js";
import { loadWallet } from "../src/lib/wallet.js";
import {
quotePrice, bip21, normaliseBuyerAddress, createOrder,
depositAddressAt, depositXpub, checkPayment,
} from "../src/lib/orders.js";
import { REGISTRAR } from "../src/registrar-config.js";
import { deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic, encodeHdPublicKey } from "@bitauth/libauth";
import { readFileSync } from "node:fs";
const tag = Math.random().toString(16).slice(2, 8);
const NAME = `qrtest-${tag}.nav`;
let failures = 0;
const check = (ok, label, detail = "") => {
console.log(` ${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
if (!ok) failures++;
};
const step = (n, s) => console.log(`\n─── ${n}. ${s} ${"─".repeat(Math.max(0, 56 - s.length))}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let client;
try {
client = await connect({ WebSocket });
} catch (e) {
console.error(`
cannot reach the chain — the test cannot run.
${e.message}
`);
process.exit(2); // 2 = environment, not a product failure
}
try {
console.log(`Pay-by-QR fulfilment — live on ${REGISTRAR.network}`);
// ── 1. the buyer: a wallet with nothing in it ────────────────────────────
step(1, "A buyer who owns no coins and runs no connector");
const buyer = await BuiltInWallet.create();
const buyerBalance = await getBalance(client, buyer.watchedAddresses);
console.log(` buyer address : ${buyer.address}`);
check(buyerBalance.sats === 0n, "buyer wallet is empty — they only need to receive");
// ── 2. quote + deposit address + QR payload ──────────────────────────────
step(2, "Quote the name and hand out a payment address");
const priceSats = quotePrice({ serviceFeeSats: REGISTRAR.serviceFee.sats });
const store = JSON.parse(readFileSync(new URL("../wallets.json", import.meta.url), "utf8"));
const root = deriveHdPrivateNodeFromSeed(await deriveSeedFromBip39Mnemonic(store.main.seed));
const orderIndex = Math.floor(Math.random() * 100000);
const deposit = depositAddressAt(orderIndex, { hdNode: root });
// The public order service should never hold a private key — prove the same
// address comes out of the xpub alone.
const encoded = encodeHdPublicKey({ network: "testnet", node: depositXpub(root) });
const xpubString = typeof encoded === "string" ? encoded : encoded.hdPublicKey;
check(
depositAddressAt(orderIndex, { xpub: xpubString }) === deposit,
"deposit address derivable from the xpub alone (no key on the public server)",
);
const order = createOrder({
index: orderIndex, name: NAME, buyerAddress: buyer.address,
priceSats, depositAddress: deposit, now: Date.now(),
});
const uri = bip21({
address: deposit, satoshis: priceSats,
label: "Silent Mode", message: `Register ${order.displayName}`,
});
console.log(` name : ${order.displayName}`);
console.log(` price : ${priceSats} sat`);
console.log(` deposit addr : ${deposit}`);
console.log(` QR payload : ${uri}`);
check(uri.startsWith(deposit), "the QR encodes the order's own address");
const before = await checkPayment(client, order);
check(before.paid === false && before.receivedSats === 0n, "nothing received yet");
// ── 3. the buyer pays from some other wallet ─────────────────────────────
step(3, "Buyer pays from an unrelated wallet (stand-in: the project wallet)");
const payer = await loadWallet("main");
const payment = await payer.send([{ cashaddr: deposit, value: Number(priceSats), unit: "sat" }]);
console.log(` payment txid : ${payment.txId}`);
let paid = null;
for (let i = 0; i < 20; i++) {
const p = await checkPayment(client, order);
if (p.paid) { paid = p; break; }
process.stdout.write(` watching the deposit address… ${i + 1}/20\r`);
await sleep(3000);
}
console.log("");
check(!!paid, "payment detected on-chain", paid ? `${paid.receivedSats} sat` : "not seen");
check(paid?.shortfallSats === 0n, "paid in full");
// ── 4. fulfilment: registrar mints TO THE BUYER ──────────────────────────
step(4, "Registrar mints the certificate to the buyer's address");
// The registrar's minting wallet is its own hot wallet, separate from the
// deposit branch. Note it derives on BCH's coin type (m/44'/145'/…) while the
// legacy project wallet in wallets.json uses m/44'/0'/… — same seed, different
// addresses. Top it up from the project wallet if it has nothing to spend.
const registrar = await BuiltInWallet.fromMnemonic(store.main.seed);
let registrarBalance = await getBalance(client, registrar.watchedAddresses);
if (registrarBalance.sats < 20_000n) {
console.log(` registrar hot wallet is low (${registrarBalance.sats} sat) — funding it`);
const top = await payer.send([{ cashaddr: registrar.address, value: 100_000, unit: "sat" }]);
console.log(` funding txid : ${top.txId}`);
for (let i = 0; i < 20; i++) {
registrarBalance = await getBalance(client, registrar.watchedAddresses);
if (registrarBalance.sats >= 20_000n) break;
process.stdout.write(` waiting for registrar funds… ${i + 1}/20\r`);
await sleep(3000);
}
console.log("");
}
check(registrarBalance.sats >= 20_000n, "registrar can fund a mint", `${registrarBalance.sats} sat`);
const buyerDest = normaliseBuyerAddress(order.buyerAddress).tokenAddress;
const result = await mintForBuyer(client, {
registrarWallet: registrar,
name: order.name,
buyerTokenAddress: buyerDest,
records: { u: "https://silentmode.st" },
onProgress: (s, d) => console.log(` · ${s}${d ? " " + JSON.stringify(d) : ""}`),
});
console.log(` mint txid : ${result.txid}`);
console.log(` category : ${result.category}`);
// ── 5. who holds the name? ───────────────────────────────────────────────
step(5, "Verify the buyer holds it and the registrar does not");
let cert = null;
for (let i = 0; i < 20; i++) {
cert = await findCertificateUtxo(client, {
addresses: buyer.watchedAddresses, category: result.category, rawName: result.name,
});
if (cert) break;
process.stdout.write(` waiting for the certificate… ${i + 1}/20\r`);
await sleep(3000);
}
console.log("");
check(!!cert, "certificate sits at the BUYER's address", cert?.address);
const registrarHolds = await findCertificateUtxo(client, {
addresses: registrar.watchedAddresses, category: result.category, rawName: result.name,
});
check(registrarHolds === null, "registrar holds NO certificate for this name");
const buyerAfter = await getBalance(client, buyer.watchedAddresses);
check(
buyerAfter.certificates.length === 1,
"buyer's wallet shows exactly one certificate",
`${buyerAfter.certificates.length} found, ${buyerAfter.sats} spendable sat`,
);
// ── 6. it resolves ───────────────────────────────────────────────────────
step(6, "The name resolves from the chain");
let entry = null;
for (let i = 0; i < 20; i++) {
entry = await resolveName(result.displayName, { WebSocket });
if (entry) break;
process.stdout.write(` waiting for the resolver… ${i + 1}/20\r`);
await sleep(3000);
}
console.log("");
check(!!entry, `${result.displayName} resolves`, entry ? JSON.stringify(entry.records) : "");
check(entry?.category === result.category, "resolver reports the category we minted");
step("=", failures === 0 ? "RESULT: ALL CHECKS PASSED" : `RESULT: ${failures} CHECK(S) FAILED`);
console.log(` ${result.displayName} is owned by ${buyer.address}`);
console.log(` the buyer never connected a wallet, and never held a coin before paying`);
} finally {
client.close();
}
process.exit(failures === 0 ? 0 : 1);

153
test/connect-shape.test.mjs Normal file
View file

@ -0,0 +1,153 @@
// Validates the payload the external-wallet path puts on the wire.
//
// WalletConnect v2 was dropped as a transport (2026-07-27), but these tests are
// NOT obsolete: WizardConnect's `SignTransactionRequest.transaction` is a
// `WcSignTransactionRequest` from @bch-wc2/interfaces — the identical WC2 tx
// object — and it is encoded with the same libauth `stringify` markers. The
// shape outlived the transport.
//
// We cannot press "approve" on someone's phone from here, so this is the next
// best thing: prove that what we would send is byte-for-byte what a wallet
// expects to receive. The failure mode this guards against is the one Deviant
// actually hit (snapshot 0057) — plain JSON.stringify silently drops Uint8Array
// and bigint, and the wallet rejects the request on schema validation.
import test from "node:test";
import assert from "node:assert/strict";
import { stringify, hexToBin, binToHex, decodeTransaction, encodeTransaction } from "@bitauth/libauth";
import { buildRegistrationTx, toWcTransactionObject } from "../src/lib/register-tx.js";
import { addressToLockingBytecode } from "../src/lib/chain.js";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { pubkeyToAddress, pubkeyToLockingBytecode, describeGenesisRequirement } from "../src/lib/connect/wallet-session.js";
import { PATHS } from "../src/lib/connect/wizardconnect.js";
const MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const A = "1111111111111111111111111111111111111111111111111111111111111111";
async function built() {
const wallet = await BuiltInWallet.fromMnemonic(MNEMONIC);
return {
wallet,
tx: buildRegistrationTx({
name: "wallet-test.p2p",
ownerAddress: wallet.address,
ownerTokenAddress: wallet.tokenAddress,
records: { u: "https://example.org" },
utxos: [{
txid: A, vout: 0, satoshis: 100_000n,
address: wallet.address, lockingBytecode: addressToLockingBytecode(wallet.address),
}],
feeAddress: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl",
feeSats: 10_000n,
addressToLockingBytecode,
}),
};
}
test("WC2 payload carries the libauth extended-JSON markers wallets validate", async () => {
const { tx } = await built();
const wc = toWcTransactionObject(tx, { broadcast: true, userPrompt: "Register wallet-test.p2p" });
const wire = stringify(wc);
assert.match(wire, /<Uint8Array: 0x[0-9a-f]+>/, "byte arrays use the Uint8Array marker");
assert.match(wire, /<bigint: \d+n>/, "satoshi values use the bigint marker");
// Why stringify is mandatory: plain JSON cannot even represent this object —
// it throws on the bigint satoshi values, and would silently flatten every
// Uint8Array to {} if it got past them.
assert.throws(() => JSON.stringify(wc), /BigInt/);
const naive = JSON.stringify(wc, (_k, v) => (typeof v === "bigint" ? Number(v) : v));
assert.ok(!naive.includes("<Uint8Array"), "hand-rolled JSON loses the byte-array encoding wallets require");
const params = JSON.parse(wire);
assert.deepEqual(Object.keys(params).sort(), ["broadcast", "sourceOutputs", "transaction", "userPrompt"]);
assert.equal(params.broadcast, true);
assert.equal(params.sourceOutputs.length, tx.transaction.inputs.length,
"one sourceOutput per input — the wallet needs both halves to know what it is spending");
});
test("every sourceOutput describes the coin being spent, not just the outpoint", async () => {
const { tx, wallet } = await built();
for (const [i, src] of tx.sourceOutputs.entries()) {
// Input half
assert.ok(src.outpointTransactionHash instanceof Uint8Array);
assert.equal(typeof src.outpointIndex, "number");
assert.ok(src.unlockingBytecode instanceof Uint8Array);
// Output half — what the wallet checks ownership against
assert.ok(src.lockingBytecode instanceof Uint8Array);
assert.equal(typeof src.valueSatoshis, "bigint");
assert.equal(
binToHex(src.lockingBytecode),
binToHex(addressToLockingBytecode(wallet.address)),
`input ${i} is locked to the connected wallet, so it will actually sign it`,
);
}
});
test("unsigned inputs carry the placeholder shape a wallet expects to replace", async () => {
const { tx } = await built();
for (const input of tx.transaction.inputs) {
assert.equal(input.unlockingBytecode.length, 100, "cashscript placeholderP2PKHUnlocker width");
assert.equal(input.unlockingBytecode[0], 0x41, "push 65-byte signature slot");
assert.equal(input.unlockingBytecode[66], 0x21, "push 33-byte pubkey slot");
assert.ok(input.unlockingBytecode.every((b, i) => i === 0 || i === 66 || b === 0), "slots are empty");
}
});
test("the transaction survives the wire as encodable bytes", async () => {
const { tx } = await built();
const encoded = encodeTransaction(tx.transaction);
const back = decodeTransaction(encoded);
assert.notEqual(typeof back, "string");
assert.equal(binToHex(encodeTransaction(back)), binToHex(encoded));
});
test("WizardConnect inputPaths triplets are [inputIndex, pathName, addressIndex]", async () => {
const { tx, wallet } = await built();
// Stand in for the manager's xpub-derived set: our own key at receive/0.
const known = new Map([[binToHex(addressToLockingBytecode(wallet.address)), { pathName: "receive", index: 0 }]]);
const inputPaths = tx.sourceOutputs.map((src, i) => {
const a = known.get(binToHex(src.lockingBytecode));
return [i, a.pathName, a.index];
});
assert.equal(inputPaths.length, tx.transaction.inputs.length);
for (const [inputIndex, pathName, addressIndex] of inputPaths) {
assert.equal(typeof inputIndex, "number");
assert.equal(typeof pathName, "string");
assert.equal(typeof addressIndex, "number", "address index is a number in the triplet, not a bigint");
assert.ok(pathName in PATHS, `"${pathName}" maps to a BIP44 branch`);
}
assert.equal(PATHS.receive, 0);
assert.equal(PATHS.change, 1);
assert.equal(PATHS.defi, 7);
});
test("a coin the connected wallet does not own is refused, not silently sent", async () => {
const { tx } = await built();
const known = new Map(); // wallet knows nothing
assert.throws(() => {
tx.sourceOutputs.forEach((src, i) => {
if (!known.get(binToHex(src.lockingBytecode))) {
throw new Error(`input ${i} is not from this wallet`);
}
});
}, /not from this wallet/);
});
test("pubkey -> address derivation matches the wallet's own", async () => {
const wallet = await BuiltInWallet.fromMnemonic(MNEMONIC);
const entry = wallet.receive[0];
assert.equal(pubkeyToAddress(entry.publicKey, "bchtest", false), wallet.address);
assert.equal(pubkeyToAddress(entry.publicKey, "bchtest", true), wallet.tokenAddress);
assert.equal(binToHex(pubkeyToLockingBytecode(entry.publicKey)), binToHex(entry.lockingBytecode));
});
test("the genesis requirement is explained honestly, with the approval count", () => {
assert.equal(describeGenesisRequirement(true).approvals, 1);
const need = describeGenesisRequirement(false);
assert.equal(need.approvals, 2);
assert.match(need.explanation, /only once/);
assert.match(need.explanation, /not a fee we charge/);
});

213
test/electrum-pool.test.mjs Normal file
View file

@ -0,0 +1,213 @@
// Multi-server electrum: availability failover and omission resistance.
//
// No network — fake clients only. That is deliberate: the failure this defends
// against (a server quietly hiding a registration) is not one you can provoke
// against real servers on demand, so it has to be testable offline or it will
// never be tested at all.
import test from "node:test";
import assert from "node:assert/strict";
import { ElectrumPool } from "../src/lib/electrum.js";
/** A fake electrum server with a scripted beacon history. */
function fakeServer(url, history, { failOn = [] } = {}) {
return {
url,
calls: [],
async call(method, params) {
this.calls.push(method);
if (failOn.includes(method)) throw new Error(`${url} refused ${method}`);
if (method === "blockchain.scripthash.get_history") return history;
if (method === "server.version") return ["fake", "1.4.2"];
return null;
},
close() { this.closed = true; },
};
}
const tx = (hash, height) => ({ tx_hash: hash, height });
const A = tx("aaaa", 100);
const B = tx("bbbb", 101);
const C = tx("cccc", 0); // unconfirmed
// ---------- availability ----------
test("call() uses the first healthy server", async () => {
const s1 = fakeServer("wss://one", [A]);
const s2 = fakeServer("wss://two", [A]);
const pool = new ElectrumPool([s1, s2]);
await pool.call("server.version");
assert.equal(s1.calls.length, 1);
assert.equal(s2.calls.length, 0, "the second server is not troubled when the first answers");
});
test("call() fails over transparently when a server dies", async () => {
const dead = fakeServer("wss://dead", [], { failOn: ["blockchain.scripthash.get_history"] });
const live = fakeServer("wss://live", [A, B]);
const pool = new ElectrumPool([dead, live]);
const r = await pool.call("blockchain.scripthash.get_history", ["sh"]);
assert.deepEqual(r, [A, B]);
});
test("when every server fails the error names each one and why", async () => {
const pool = new ElectrumPool([
fakeServer("wss://one", [], { failOn: ["x"] }),
fakeServer("wss://two", [], { failOn: ["x"] }),
]);
await assert.rejects(
() => pool.call("x"),
(e) => {
assert.equal(e.code, "NO_ELECTRUM");
assert.equal(e.failures.length, 2);
assert.match(e.message, /wss:\/\/one/);
assert.match(e.message, /wss:\/\/two/);
assert.match(e.message, /non-standard\s+ports/, "tells the operator what to actually check");
return true;
},
);
});
test("callAll reports per-server outcomes without throwing on partial failure", async () => {
const pool = new ElectrumPool([
fakeServer("wss://ok", [A]),
fakeServer("wss://bad", [], { failOn: ["blockchain.scripthash.get_history"] }),
]);
const out = await pool.callAll("blockchain.scripthash.get_history", ["sh"]);
assert.equal(out.length, 2);
assert.equal(out.filter((o) => o.ok).length, 1);
assert.equal(out.find((o) => !o.ok).url, "wss://bad");
});
// ---------- omission resistance (the reason this exists) ----------
test("a server hiding a registration cannot hide it from the pool", async () => {
// s2 omits B — the exact attack: hide a REG so a taken name looks available.
const honest = fakeServer("wss://honest", [A, B]);
const liar = fakeServer("wss://liar", [A]);
const pool = new ElectrumPool([liar, honest]); // liar queried first, deliberately
const { history, disagreements } = await pool.unionHistory("sh");
const hashes = history.map((h) => h.tx_hash).sort();
assert.deepEqual(hashes, ["aaaa", "bbbb"], "the hidden registration is still in the union");
assert.equal(disagreements.length, 1, "the omission is reported, not silently patched over");
assert.equal(disagreements[0].url, "wss://liar");
assert.deepEqual(disagreements[0].missing, ["bbbb"]);
});
test("union takes MORE history, never less — a majority cannot erase a registration", async () => {
// Two servers omit B, one has it. A quorum vote would drop B; union keeps it.
const pool = new ElectrumPool([
fakeServer("wss://a", [A]),
fakeServer("wss://b", [A]),
fakeServer("wss://c", [A, B]),
]);
const { history } = await pool.unionHistory("sh");
assert.ok(
history.some((h) => h.tx_hash === "bbbb"),
"outvoted is not the same as untrue — two stale servers must not erase a real registration",
);
});
test("a confirmed height wins over the same tx reported as unconfirmed", async () => {
const pool = new ElectrumPool([
fakeServer("wss://behind", [tx("cccc", 0)]),
fakeServer("wss://ahead", [tx("cccc", 500)]),
]);
const { history } = await pool.unionHistory("sh");
assert.equal(history.find((h) => h.tx_hash === "cccc").height, 500);
});
test("the lowest confirmed height wins when servers disagree about a block", async () => {
const pool = new ElectrumPool([
fakeServer("wss://a", [tx("dddd", 900)]),
fakeServer("wss://b", [tx("dddd", 800)]),
]);
const { history } = await pool.unionHistory("sh");
assert.equal(history.find((h) => h.tx_hash === "dddd").height, 800);
});
test("identical servers produce no disagreement noise", async () => {
const pool = new ElectrumPool([
fakeServer("wss://a", [A, B, C]),
fakeServer("wss://b", [A, B, C]),
]);
const { disagreements, servers } = await pool.unionHistory("sh");
assert.equal(servers, 2);
assert.equal(disagreements.length, 0);
});
test("unreachable servers are surfaced, and the rest still answer", async () => {
const pool = new ElectrumPool([
fakeServer("wss://down", [], { failOn: ["blockchain.scripthash.get_history"] }),
fakeServer("wss://up", [A, B]),
]);
const { history, servers, unreachable } = await pool.unionHistory("sh");
assert.equal(servers, 1);
assert.equal(unreachable.length, 1);
assert.equal(unreachable[0].url, "wss://down");
assert.equal(history.length, 2, "a degraded pool still resolves");
});
test("unionHistory refuses to answer from nothing", async () => {
const pool = new ElectrumPool([
fakeServer("wss://a", [], { failOn: ["blockchain.scripthash.get_history"] }),
]);
await assert.rejects(() => pool.unionHistory("sh"), (e) => e.code === "NO_ELECTRUM");
});
test("an empty pool is refused at construction", () => {
assert.throws(() => new ElectrumPool([]), /at least one client/);
});
test("close() closes every connection", () => {
const a = fakeServer("wss://a", []);
const b = fakeServer("wss://b", []);
new ElectrumPool([a, b]).close();
assert.equal(a.closed, true);
assert.equal(b.closed, true);
});
// ---------- the resolver uses the union ----------
test("buildIndex takes the union when handed a pool", async () => {
const { buildIndex, BEACON_SCRIPTHASH } = await import("../src/lib/resolver-web.js");
// Neither server can produce a valid registration from these stubs, so the
// index is empty either way — what matters is that the union path ran and the
// omission was recorded where a caller can see it.
const honest = fakeServer("wss://honest", [A, B]);
const liar = fakeServer("wss://liar", [A]);
honest.call = async function (m, p) {
if (m === "blockchain.scripthash.get_history") return [A, B];
if (m === "blockchain.transaction.get") return { vout: [], vin: [] };
return null;
};
liar.call = async function (m, p) {
if (m === "blockchain.scripthash.get_history") return [A];
if (m === "blockchain.transaction.get") return { vout: [], vin: [] };
return null;
};
const pool = new ElectrumPool([liar, honest]);
const names = await buildIndex({ client: pool });
assert.ok(names instanceof Map);
assert.ok(names.integrity, "integrity metadata is attached for callers that care");
assert.equal(names.integrity.servers, 2);
assert.equal(names.integrity.disagreements.length, 1);
assert.equal(names.integrity.disagreements[0].url, "wss://liar");
assert.equal(BEACON_SCRIPTHASH.length, 64);
});
test("buildIndex still works with a single plain client (no pool)", async () => {
const { buildIndex } = await import("../src/lib/resolver-web.js");
const solo = fakeServer("wss://solo", [A]);
solo.call = async function (m) {
if (m === "blockchain.scripthash.get_history") return [A];
if (m === "blockchain.transaction.get") return { vout: [], vin: [] };
return null;
};
const names = await buildIndex({ client: solo });
assert.ok(names instanceof Map);
assert.equal(names.integrity, undefined, "no pool, no cross-check claim");
});

268
test/orders.test.mjs Normal file
View file

@ -0,0 +1,268 @@
// Pay-by-QR order flow: pricing, deposit addresses, BIP21, and the registrar
// minting on a buyer's behalf.
//
// The load-bearing property under test is that the registrar never ends up
// holding the name. Everything else is recoverable; that is not.
import test from "node:test";
import assert from "node:assert/strict";
import { binToHex, deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic, encodeHdPublicKey } from "@bitauth/libauth";
import {
bip21,
quotePrice,
normaliseBuyerAddress,
createOrder,
depositAddressAt,
depositXpub,
ORDER_STATUS,
DEPOSIT_PATH,
} from "../src/lib/orders.js";
import { buildRegistrationTx } from "../src/lib/register-tx.js";
import { addressToLockingBytecode, toTokenAddress } from "../src/lib/chain.js";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { signTransaction } from "../src/lib/sign-tx.js";
const REGISTRAR_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const BUYER = "bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs";
const A = "1111111111111111111111111111111111111111111111111111111111111111";
async function rootNode() {
const seed = await deriveSeedFromBip39Mnemonic(REGISTRAR_MNEMONIC);
return deriveHdPrivateNodeFromSeed(seed);
}
// ---------- pricing ----------
test("the price covers what the mint actually costs the registrar", () => {
// 10k service fee + 500 chain + 1000 certificate dust + 600 beacon = 12100 -> 13000
const p = quotePrice({ serviceFeeSats: 10_000n });
assert.ok(p >= 12_100n, "must at least cover fee + chain + dust + beacon");
assert.equal(p % 1000n, 0n, "rounded to a tidy number for the QR");
assert.equal(p, 13_000n);
});
test("a zero service fee still charges the chain costs, not nothing", () => {
const p = quotePrice({ serviceFeeSats: 0n });
assert.ok(p >= 2100n, `free names must not cost the registrar money — got ${p}`);
});
test("rounding never lands below the true cost", () => {
for (const fee of [0n, 1n, 999n, 1000n, 10_000n, 123_456n]) {
const raw = fee + 500n + 1000n + 600n;
assert.ok(quotePrice({ serviceFeeSats: fee }) >= raw, `fee ${fee} underpriced`);
}
});
// ---------- BIP21 ----------
test("BIP21 URI is the shape wallets actually parse", () => {
const uri = bip21({ address: BUYER, satoshis: 13_000n, label: "Silent Mode", message: "sirius.nav" });
assert.ok(uri.startsWith("bchtest:qqmcn373"), "keeps the cashaddr prefix");
assert.match(uri, /\?amount=0\.00013(&|$)/, "amount is BCH, with trailing zeros stripped");
assert.match(uri, /label=Silent%20Mode/);
assert.match(uri, /message=sirius\.nav/);
});
test("BIP21 amounts never carry trailing zeros (some wallets reject them)", () => {
assert.match(bip21({ address: BUYER, satoshis: 100_000_000n }), /amount=1(&|$)/);
assert.match(bip21({ address: BUYER, satoshis: 1000n }), /amount=0\.00001(&|$)/);
assert.ok(!bip21({ address: BUYER, satoshis: 13_000n }).includes("0.00013000"));
});
// ---------- the buyer's address: the field that loses names ----------
test("a plain q-address is silently upgraded to token-aware", () => {
const r = normaliseBuyerAddress(BUYER);
assert.equal(r.address, BUYER);
assert.match(r.tokenAddress, /^bchtest:z/);
assert.equal(r.wasTokenAware, false);
assert.equal(
binToHex(addressToLockingBytecode(r.address)),
binToHex(addressToLockingBytecode(r.tokenAddress)),
"same key either way — the prefix is only a wallet-safety flag",
);
});
test("a token-aware address is accepted as-is and flagged", () => {
const r = normaliseBuyerAddress(toTokenAddress(BUYER));
assert.equal(r.wasTokenAware, true);
assert.equal(r.tokenAddress, toTokenAddress(BUYER));
});
test("a bare address without the prefix still works", () => {
const r = normaliseBuyerAddress(BUYER.split(":")[1]);
assert.equal(r.address, BUYER);
});
test("a mainnet address on a chipnet registry is refused, not silently accepted", () => {
assert.throws(
() => normaliseBuyerAddress("bitcoincash:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"),
/chipnet/,
"sending a chipnet certificate to a mainnet address would lose it",
);
});
test("typos and empty input are rejected with something a human can act on", () => {
assert.throws(() => normaliseBuyerAddress(""), /enter the address/);
assert.throws(() => normaliseBuyerAddress(" "), /enter the address/);
assert.throws(() => normaliseBuyerAddress("bchtest:qqmcn373TYPOplhjrvs5"), /typo/);
assert.throws(() => normaliseBuyerAddress("not an address at all"), /typo/);
});
// ---------- deposit addresses ----------
test("every order gets its own deposit address", async () => {
const node = await rootNode();
const seen = new Set();
for (let i = 0; i < 25; i++) {
const a = depositAddressAt(i, { hdNode: node });
assert.match(a, /^bchtest:q/);
assert.ok(!seen.has(a), `deposit address repeated at index ${i} — orders would be indistinguishable`);
seen.add(a);
}
});
test("the public service can derive deposit addresses from an xpub alone", async () => {
const node = await rootNode();
const xpub = encodeHdPublicKey({ network: "testnet", node: depositXpub(node) });
const xpubString = typeof xpub === "string" ? xpub : xpub.hdPublicKey;
for (let i = 0; i < 5; i++) {
assert.equal(
depositAddressAt(i, { xpub: xpubString }),
depositAddressAt(i, { hdNode: node }),
`xpub and private derivation must agree at index ${i}`,
);
}
});
test("deposit addresses sit on their own branch, away from spending coins", () => {
assert.match(DEPOSIT_PATH(0), /^m\/44'\/145'\/0'\/3\/0$/);
assert.notEqual(DEPOSIT_PATH(0), "m/44'/145'/0'/0/0");
});
test("deriving without a key source is refused rather than guessed", () => {
assert.throws(() => depositAddressAt(0, {}), /xpub or an hdNode/);
});
// ---------- orders ----------
test("a new order starts unpaid and carries both address forms", async () => {
const node = await rootNode();
const order = createOrder({
index: 7,
name: "sirius.nav",
buyerAddress: BUYER,
priceSats: 13_000n,
depositAddress: depositAddressAt(7, { hdNode: node }),
now: 1_700_000_000_000,
});
assert.equal(order.status, ORDER_STATUS.quoted);
assert.equal(order.name, "sirius.nav");
assert.equal(order.displayName, "sirius.nav");
assert.equal(order.buyerAddress, BUYER);
assert.match(order.buyerTokenAddress, /^bchtest:z/);
assert.equal(order.mintTxid, null);
assert.ok(order.expiresAt > order.createdAt, "a quote lapses — it is not a reservation");
});
test("a .bch order keeps the bare on-chain name but displays the TLD", async () => {
const node = await rootNode();
const order = createOrder({
index: 0, name: "sirius.bch", buyerAddress: BUYER, priceSats: 13_000n,
depositAddress: depositAddressAt(0, { hdNode: node }), now: 0,
});
assert.equal(order.name, "sirius", "on-chain form for .bch is the bare label");
assert.equal(order.displayName, "sirius.bch");
});
// ---------- the mint: who ends up holding the name ----------
test("registrar-funded mint sends the certificate to the buyer and change to the registrar", async () => {
const registrar = await BuiltInWallet.fromMnemonic(REGISTRAR_MNEMONIC);
const buyerToken = toTokenAddress(BUYER);
const built = buildRegistrationTx({
name: "sirius.nav",
ownerAddress: buyerToken,
ownerTokenAddress: buyerToken,
changeAddress: registrar.address, // registrar funded it
records: {},
utxos: [{
txid: A, vout: 0, satoshis: 200_000n,
address: registrar.address,
lockingBytecode: addressToLockingBytecode(registrar.address),
}],
feeSats: 0n, // buyer already paid off-transaction
addressToLockingBytecode,
});
const cert = built.transaction.outputs[built.outputMap.certificate];
const change = built.transaction.outputs[built.outputMap.change];
assert.equal(
binToHex(cert.lockingBytecode),
binToHex(addressToLockingBytecode(buyerToken)),
"the certificate must be locked to the BUYER — this is the whole trust argument",
);
assert.equal(
binToHex(change.lockingBytecode),
binToHex(addressToLockingBytecode(registrar.address)),
"change returns to the registrar who funded it",
);
assert.notEqual(binToHex(cert.lockingBytecode), binToHex(change.lockingBytecode));
// No registrar-controlled output may carry the certificate, at any index.
const registrarLock = binToHex(addressToLockingBytecode(registrar.address));
const registrarHoldsToken = built.transaction.outputs.some(
(o) => o.token && binToHex(o.lockingBytecode) === registrarLock,
);
assert.equal(registrarHoldsToken, false, "the registrar must never hold the name, not even for one block");
// And it is signable by the registrar's key, since the registrar's coins fund it.
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => registrar.keyFor(h));
assert.match(signed.txid, /^[0-9a-f]{64}$/);
});
test("no service-fee output is added when the buyer paid off-transaction", async () => {
const registrar = await BuiltInWallet.fromMnemonic(REGISTRAR_MNEMONIC);
const built = buildRegistrationTx({
name: "sirius.nav",
ownerAddress: toTokenAddress(BUYER),
changeAddress: registrar.address,
utxos: [{
txid: A, vout: 0, satoshis: 200_000n,
address: registrar.address,
lockingBytecode: addressToLockingBytecode(registrar.address),
}],
feeSats: 0n,
addressToLockingBytecode,
});
assert.equal(built.outputMap.serviceFee, undefined, "billing twice would be theft, not a rounding error");
assert.equal(built.costs.serviceFeeSats, 0n);
});
test("self-custody builds are unchanged — change still returns to the owner", async () => {
const wallet = await BuiltInWallet.fromMnemonic(REGISTRAR_MNEMONIC);
const built = buildRegistrationTx({
name: "sirius.nav",
ownerAddress: wallet.address,
ownerTokenAddress: wallet.tokenAddress,
// changeAddress deliberately omitted
utxos: [{
txid: A, vout: 0, satoshis: 200_000n,
address: wallet.address,
lockingBytecode: addressToLockingBytecode(wallet.address),
}],
feeSats: 5000n,
feeAddress: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl",
addressToLockingBytecode,
});
assert.equal(built.outputMap.change, 0, "change stays at vout 0 so the next registration is self-sufficient");
assert.equal(
binToHex(built.transaction.outputs[0].lockingBytecode),
binToHex(addressToLockingBytecode(wallet.address)),
);
});

125
test/plans.test.mjs Normal file
View file

@ -0,0 +1,125 @@
// The plan catalogue and bundle pricing.
//
// Most of these assert honesty properties rather than arithmetic: that a free
// option is never priced, that only a plan where WE hold the bytes is flagged as
// creating an obligation, and that every recurring plan tells the buyer what
// happens to their NAME when hosting lapses. Those are the claims that get
// quietly wrong when copy is edited months later, and they are exactly the ones
// no one notices until a customer is upset.
import test from "node:test";
import assert from "node:assert/strict";
import {
HOSTING_PLANS, VPS_REFERRALS, PLAN_KIND,
planById, paidPlans, freePlans, quoteBundle,
} from "../src/lib/plans.js";
import { quotePrice } from "../src/lib/orders.js";
import { REGISTRAR } from "../src/registrar-config.js";
const NAME_PRICE = quotePrice({ serviceFeeSats: REGISTRAR.serviceFee.sats });
test("only one plan is actually hosting-as-a-service", () => {
const managed = HOSTING_PLANS.filter((p) => p.kind === PLAN_KIND.managed);
assert.equal(managed.length, 1, "selling more 'plans' than we run is how a storefront starts lying");
assert.equal(managed[0].id, "sia-managed-1y");
assert.equal(managed[0].weStore, true);
});
test("every free plan is genuinely free and stores nothing of ours", () => {
for (const p of freePlans()) {
assert.equal(p.priceSats, 0n, `${p.id} is listed free but priced`);
assert.equal(p.weStore, false, `${p.id} is listed free but we hold the bytes`);
assert.equal(p.recurring, false, `${p.id} is free but recurring — that cannot both be true`);
}
assert.ok(freePlans().length >= 3, "most options cost the buyer nothing; the page should say so");
});
test("paid plans are exactly the ones where we store bytes", () => {
const paid = paidPlans().map((p) => p.id).sort();
const stores = HOSTING_PLANS.filter((p) => p.weStore).map((p) => p.id).sort();
assert.deepEqual(paid, stores, "we should charge for storage and nothing else");
});
test("every recurring plan explains what happens to the NAME on lapse", () => {
for (const p of HOSTING_PLANS.filter((x) => x.recurring)) {
assert.ok(p.onLapse, `${p.id} is recurring but says nothing about lapsing`);
assert.match(p.onLapse, /name is unaffected/i, "the flagship claim must survive a lapsed subscription");
assert.match(p.onLapse, /repoint/i, "the buyer needs to know the remedy is free and in their hands");
}
});
test("non-recurring plans do not carry lapse language", () => {
for (const p of HOSTING_PLANS.filter((x) => !x.recurring)) {
assert.equal(p.onLapse, null, `${p.id} cannot lapse, so it must not imply it can`);
}
});
test("referrals are not plans and carry no price of ours", () => {
const ids = new Set(HOSTING_PLANS.map((p) => p.id));
for (const r of VPS_REFERRALS) {
assert.ok(!ids.has(r.id), `${r.id} appears in both lists — a referral is not something we sell`);
assert.ok(r.url.startsWith("https://"), "referral needs a real destination");
assert.equal(r.priceSats, undefined, "we do not price other people's products");
}
});
// ---------- bundle pricing ----------
test("a name on its own is just the name price", () => {
const q = quoteBundle({ namePriceSats: NAME_PRICE });
assert.equal(q.totalSats, NAME_PRICE);
assert.equal(q.lines.length, 1);
assert.equal(q.plan, null);
assert.equal(q.createsHostingObligation, false);
assert.equal(q.recurring, false);
});
test("name + managed hosting is one payment with itemised lines", () => {
const plan = planById("sia-managed-1y");
const q = quoteBundle({ namePriceSats: NAME_PRICE, planId: "sia-managed-1y" });
assert.equal(q.totalSats, NAME_PRICE + plan.priceSats);
assert.equal(q.lines.length, 2, "one payment, two lines — not two orders to reconcile");
assert.equal(q.lines[0].id, "name");
assert.match(q.lines[1].label, /Managed Sia hosting/);
assert.match(q.lines[1].label, /1y/, "the period belongs in the label, where the buyer reads it");
});
test("choosing a free plan adds no charge and no obligation", () => {
for (const p of freePlans()) {
const q = quoteBundle({ namePriceSats: NAME_PRICE, planId: p.id });
assert.equal(q.totalSats, NAME_PRICE, `${p.id} must not add cost`);
assert.equal(q.lines.length, 1, `${p.id} must not add a billing line`);
assert.equal(q.createsHostingObligation, false, `${p.id} must not make us a host`);
}
});
test("only the managed plan flags a hosting obligation", () => {
// This flag is what the UI keys the "we will be storing your files" disclosure
// off. Getting it wrong either hides a real liability or scares people off a
// free option that never touches our infrastructure.
const managed = quoteBundle({ namePriceSats: NAME_PRICE, planId: "sia-managed-1y" });
assert.equal(managed.createsHostingObligation, true);
assert.equal(managed.recurring, true);
const onchain = quoteBundle({ namePriceSats: NAME_PRICE, planId: "onchain" });
assert.equal(onchain.createsHostingObligation, false);
});
test("an unknown plan is refused rather than silently ignored", () => {
// Silently dropping it would sell a name, take the money, and quietly not
// provide the hosting the buyer picked.
assert.throws(() => quoteBundle({ namePriceSats: NAME_PRICE, planId: "sia-managed-10y" }), /unknown hosting plan/);
});
test("bundle totals stay bigint throughout", () => {
const q = quoteBundle({ namePriceSats: NAME_PRICE, planId: "sia-managed-1y" });
assert.equal(typeof q.totalSats, "bigint");
for (const l of q.lines) assert.equal(typeof l.sats, "bigint", `${l.id} must not drift to Number`);
});
test("every plan maps to a real BNS record type", () => {
const valid = new Set(["h", "u", "ip", "s3", "tls"]);
for (const p of HOSTING_PLANS) {
assert.ok(valid.has(p.record), `${p.id} points at "${p.record}", which the resolver does not implement`);
}
});

359
test/register-tx.test.mjs Normal file
View file

@ -0,0 +1,359 @@
// Offline validation of the certificate-minting tx builder.
// No network, no funds — synthetic UTXOs only. Run: node --test test/
import test from "node:test";
import assert from "node:assert/strict";
import {
buildRegistrationTx,
buildUpdateTx,
buildPrepTx,
normalizeName,
encodePayload,
opReturnScript,
hasGenesisInput,
NoGenesisInputError,
InsufficientFundsError,
BEACON,
CERTIFICATE_DUST_SAT,
BEACON_DUST_SAT,
} from "../src/lib/register-tx.js";
import { signTransaction, txidOf } from "../src/lib/sign-tx.js";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { addressToLockingBytecode, toTokenAddress } from "../src/lib/chain.js";
import { parseOpReturn } from "../src/lib/bns.js";
import {
encodeTransaction,
decodeTransaction,
binToHex,
hexToBin,
binToUtf8,
} from "@bitauth/libauth";
// A fixed phrase so the test is deterministic. TEST ONLY — never funded.
const TEST_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const FEE_ADDRESS = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
const utxo = (txid, vout, sats, wallet, token) => ({
txid,
vout,
satoshis: BigInt(sats),
address: wallet.address,
lockingBytecode: addressToLockingBytecode(wallet.address),
...(token ? { token } : {}),
});
const A = "1111111111111111111111111111111111111111111111111111111111111111";
const B = "2222222222222222222222222222222222222222222222222222222222222222";
const C = "3333333333333333333333333333333333333333333333333333333333333333";
async function fixture() {
const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC);
return {
wallet,
base: {
ownerAddress: wallet.address,
ownerTokenAddress: wallet.tokenAddress,
feeAddress: FEE_ADDRESS,
addressToLockingBytecode,
},
};
}
test("normalizeName keeps .bch bare and other TLDs qualified", () => {
assert.equal(normalizeName("Alice"), "alice");
assert.equal(normalizeName("alice.bch"), "alice");
assert.equal(normalizeName("alice.p2p"), "alice.p2p");
assert.equal(normalizeName("www.alice.p2p"), "alice.p2p");
assert.throws(() => normalizeName("bad_name.bch"));
assert.throws(() => normalizeName("this-label-is-far-too-long-to-be-a-valid-bns-name.bch"));
});
test("payload round-trips through the on-chain parser", () => {
const script = opReturnScript(encodePayload("REG", "alice.p2p", { u: "https://x.example" }));
const parsed = parseOpReturn(binToHex(script));
assert.deepEqual(parsed, { o: "REG", n: "alice.p2p", r: { u: "https://x.example" } });
});
test("payload over 200 bytes is rejected", () => {
assert.throws(() => encodePayload("REG", "alice", { h: "x".repeat(300) }), /records too large/);
});
test("registration mints the certificate to the buyer, not to us", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { u: "https://example.org" },
feeSats: 5000n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
const cert = built.transaction.outputs[built.outputMap.certificate];
assert.ok(cert.token, "certificate output carries token data");
assert.equal(
binToHex(cert.lockingBytecode),
binToHex(addressToLockingBytecode(wallet.tokenAddress)),
"certificate is locked to the buyer's own key",
);
assert.equal(binToUtf8(cert.token.nft.commitment), "alice.p2p");
assert.equal(cert.token.nft.capability, "none");
assert.equal(cert.valueSatoshis, CERTIFICATE_DUST_SAT);
});
test("category ID equals the genesis input's txid (CashTokens consensus rule)", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
utxos: [utxo(B, 3, 50_000, wallet), utxo(A, 0, 50_000, wallet)],
});
assert.equal(built.inputUtxos[0].vout, 0, "input 0 is the genesis input");
assert.equal(built.inputUtxos[0].txid, A);
assert.equal(built.category, A, "category ID is the genesis outpoint's txid");
const cert = built.transaction.outputs[built.outputMap.certificate];
assert.equal(binToHex(cert.token.category), A);
});
test("no vout-0 coin -> NoGenesisInputError (and prep tx fixes it)", async () => {
const { wallet, base } = await fixture();
const utxos = [utxo(A, 1, 100_000, wallet), utxo(B, 2, 100_000, wallet)];
assert.equal(hasGenesisInput(utxos), false);
assert.throws(
() => buildRegistrationTx({ ...base, name: "alice", utxos }),
NoGenesisInputError,
);
const prep = buildPrepTx({ ownerAddress: wallet.address, utxos, addressToLockingBytecode });
assert.equal(
binToHex(prep.transaction.outputs[0].lockingBytecode),
binToHex(addressToLockingBytecode(wallet.address)),
"prep tx output 0 pays the owner, making a genesis-eligible coin",
);
});
test("change lands at vout 0 so the next registration is self-sufficient", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
feeSats: 1000n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
assert.equal(built.outputMap.change, 0);
assert.ok(built.costs.changeSats > 0n);
assert.equal(
binToHex(built.transaction.outputs[0].lockingBytecode),
binToHex(addressToLockingBytecode(wallet.address)),
);
});
test("beacon, service fee and OP_RETURN are all present and correct", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { ip: "203.0.113.9" },
feeSats: 25_000n,
utxos: [utxo(A, 0, 200_000, wallet)],
});
const o = built.transaction.outputs;
assert.equal(o[built.outputMap.beacon].valueSatoshis, BEACON_DUST_SAT);
assert.equal(
binToHex(o[built.outputMap.beacon].lockingBytecode),
binToHex(addressToLockingBytecode(BEACON)),
);
assert.equal(o[built.outputMap.serviceFee].valueSatoshis, 25_000n);
assert.equal(
binToHex(o[built.outputMap.serviceFee].lockingBytecode),
binToHex(addressToLockingBytecode(FEE_ADDRESS)),
);
assert.deepEqual(parseOpReturn(binToHex(o[built.outputMap.opReturn].lockingBytecode)), {
o: "REG",
n: "alice.p2p",
r: { ip: "203.0.113.9" },
});
assert.equal(o[built.outputMap.opReturn].valueSatoshis, 0n);
});
test("the books balance: inputs = outputs + chain fee", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
feeSats: 7777n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
const outSum = built.transaction.outputs.reduce((s, o) => s + o.valueSatoshis, 0n);
const inSum = built.inputUtxos.reduce((s, u) => s + u.satoshis, 0n);
assert.equal(inSum, outSum + built.costs.chainFeeSats);
assert.equal(inSum, 100_000n);
});
test("the fee actually covers the transaction at 1 sat/byte", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
feeSats: 1000n,
utxos: [utxo(A, 0, 60_000, wallet), utxo(B, 1, 60_000, wallet)],
});
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const size = hexToBin(signed.hex).length;
assert.ok(
built.costs.chainFeeSats >= BigInt(size),
`fee ${built.costs.chainFeeSats} must cover ${size} bytes`,
);
});
test("certificate UTXOs are never spent as change", async () => {
const { wallet, base } = await fixture();
const certToken = {
category: C,
amount: 0n,
nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("held.p2p")) },
};
const built = buildRegistrationTx({
...base,
name: "alice",
utxos: [
utxo(A, 0, 100_000, wallet),
utxo(C, 0, 1000, wallet, certToken), // an already-owned name — must not be touched
],
});
assert.ok(
built.inputUtxos.every((u) => !u.token),
"no token-bearing UTXO may be selected as a funding coin",
);
});
test("insufficient funds is reported, not silently under-paid", async () => {
const { wallet, base } = await fixture();
assert.throws(
() => buildRegistrationTx({ ...base, name: "alice", feeSats: 50_000n, utxos: [utxo(A, 0, 2000, wallet)] }),
InsufficientFundsError,
);
});
test("signing produces a well-formed tx that survives an encode/decode round trip", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { u: "https://example.org" },
feeSats: 5000n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const redecoded = decodeTransaction(hexToBin(signed.hex));
assert.notEqual(typeof redecoded, "string", `decode failed: ${redecoded}`);
assert.equal(binToHex(encodeTransaction(redecoded)), signed.hex, "encode/decode is lossless");
// Byte order: what we put in the outpoint is what comes back out.
assert.equal(binToHex(redecoded.inputs[0].outpointTransactionHash), A);
const cert = redecoded.outputs.find((o) => o.token?.nft);
assert.equal(binToHex(cert.token.category), A);
assert.equal(binToUtf8(cert.token.nft.commitment), "alice.p2p");
// Every input is really signed (placeholder is all-zero pushes).
for (const i of redecoded.inputs) {
assert.equal(i.unlockingBytecode.length, 100);
assert.ok(i.unlockingBytecode.some((b) => b !== 0), "input carries a real signature");
}
assert.match(signed.txid, /^[0-9a-f]{64}$/);
assert.equal(signed.txid, txidOf(hexToBin(signed.hex)));
});
test("the resolver's own rules accept the transaction we build", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { u: "https://example.org" },
utxos: [utxo(A, 0, 100_000, wallet)],
});
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const tx = decodeTransaction(hexToBin(signed.hex));
// Mirror of buildIndex()'s REG validation.
let payload = null;
for (const o of tx.outputs) payload = parseOpReturn(binToHex(o.lockingBytecode)) ?? payload;
assert.ok(payload, "a BNS1 payload is discoverable");
assert.equal(payload.o, "REG");
const commitment = binToHex(new TextEncoder().encode(payload.n));
const mint = tx.outputs.find((o) => o.token?.nft && binToHex(o.token.nft.commitment) === commitment);
assert.ok(mint, "the same tx mints a certificate whose commitment is the name");
const paysBeacon = tx.outputs.some(
(o) =>
binToHex(o.lockingBytecode) === binToHex(addressToLockingBytecode(BEACON)) &&
o.valueSatoshis === BEACON_DUST_SAT,
);
assert.ok(paysBeacon, "the beacon is paid so the registration is discoverable");
});
test("update tx moves the certificate back to the owner as ownership proof", async () => {
const { wallet, base } = await fixture();
const certUtxo = utxo(C, 1, 1000, wallet, {
category: C,
amount: 0n,
nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("alice.p2p")) },
});
const built = buildUpdateTx({
...base,
name: "alice.p2p",
records: { ip: "198.51.100.7" },
certificateUtxo: certUtxo,
utxos: [utxo(A, 0, 50_000, wallet)],
});
assert.equal(built.inputUtxos[0], certUtxo, "the certificate is spent — that is the proof");
const cert = built.transaction.outputs[built.outputMap.certificate];
assert.equal(binToHex(cert.token.category), C, "same category survives the update");
assert.deepEqual(
parseOpReturn(binToHex(built.transaction.outputs[built.outputMap.opReturn].lockingBytecode)),
{ o: "UPD", n: "alice.p2p", r: { ip: "198.51.100.7" } },
);
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
assert.notEqual(typeof decodeTransaction(hexToBin(signed.hex)), "string");
});
test("updating the wrong name is refused", async () => {
const { wallet, base } = await fixture();
const certUtxo = utxo(C, 1, 1000, wallet, {
category: C,
amount: 0n,
nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("bob.p2p")) },
});
assert.throws(
() =>
buildUpdateTx({
...base,
name: "alice.p2p",
certificateUtxo: certUtxo,
utxos: [utxo(A, 0, 50_000, wallet)],
}),
/commitment mismatch/,
);
});
test("wallet: encrypt/decrypt round trip, wrong password rejected", async () => {
const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC);
const blob = await wallet.encrypt("correct horse battery staple");
assert.ok(!blob.includes("abandon"), "the seed phrase is not stored in the clear");
const back = await BuiltInWallet.decrypt(blob, "correct horse battery staple");
assert.equal(back.mnemonic, TEST_MNEMONIC);
assert.equal(back.address, wallet.address);
await assert.rejects(() => BuiltInWallet.decrypt(blob, "wrong"), /wrong password/);
});
test("wallet: token address and plain address share one key", async () => {
const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC);
assert.match(wallet.address, /^bchtest:q/);
assert.match(wallet.tokenAddress, /^bchtest:z/);
assert.equal(
binToHex(addressToLockingBytecode(wallet.address)),
binToHex(addressToLockingBytecode(wallet.tokenAddress)),
);
assert.equal(toTokenAddress(wallet.address), wallet.tokenAddress);
assert.ok(wallet.keyFor(binToHex(addressToLockingBytecode(wallet.address))));
});