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:
commit
a45320db7d
56 changed files with 9172 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
76
BROWSER-PROMPT.md
Normal 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
90
DESIGN-hosting-pact.md
Normal 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
47
ENGINE.md
Normal 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
122
OPERATIONS-electrum.md
Normal 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
95
PROTOCOL.md
Normal 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
59
README.md
Normal 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
80
ROADMAP.md
Normal 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
17
ariadne.config.json
Normal 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
186
build-web.mjs
Normal 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
BIN
namespaces.pdf
Normal file
Binary file not shown.
2004
package-lock.json
generated
Normal file
2004
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
package.json
Normal file
28
package.json
Normal 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
163
setup/install.ps1
Normal 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
62
setup/uninstall.ps1
Normal 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
34
sia-test.html
Normal 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
7
src/config.js
Normal 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
266
src/daemon/bnsd.js
Normal 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
24
src/daemon/dig.js
Normal 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");
|
||||
157
src/gateway/public-gateway.mjs
Normal file
157
src/gateway/public-gateway.mjs
Normal 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
201
src/lib/bns.js
Normal 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
194
src/lib/ca.js
Normal 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
141
src/lib/chain.js
Normal 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 };
|
||||
69
src/lib/connect/wallet-session.js
Normal file
69
src/lib/connect/wallet-session.js
Normal 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.",
|
||||
};
|
||||
}
|
||||
178
src/lib/connect/wizardconnect.js
Normal file
178
src/lib/connect/wizardconnect.js
Normal 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
56
src/lib/dns-patch.js
Normal 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
225
src/lib/electrum.js
Normal 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
29
src/lib/gen-wallets.js
Normal 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
18
src/lib/issue-cert.js
Normal 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
278
src/lib/orders.js
Normal 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
164
src/lib/plans.js
Normal 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
549
src/lib/register-tx.js
Normal 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
496
src/lib/registrar.js
Normal 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
17
src/lib/resolve-web.js
Normal 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
325
src/lib/resolver-web.js
Normal 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
65
src/lib/sia-upload.js
Normal 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
149
src/lib/sign-message.js
Normal 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
70
src/lib/sign-tx.js
Normal 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
84
src/lib/tlds.js
Normal 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
188
src/lib/wallet-web.js
Normal 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
10
src/lib/wallet.js
Normal 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
18
src/register.js
Normal 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
89
src/registrar-config.js
Normal 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
18
src/resolve.js
Normal 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
15
src/update.js
Normal 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
165
src/wallet-app.js
Normal 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
77
src/web/libauth-entry.js
Normal 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
47
src/web/register-entry.js
Normal 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
198
test/chipnet-acceptance.mjs
Normal 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);
|
||||
208
test/chipnet-escrow-refund.mjs
Normal file
208
test/chipnet-escrow-refund.mjs
Normal 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
188
test/chipnet-paybyqr.mjs
Normal 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
153
test/connect-shape.test.mjs
Normal 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
213
test/electrum-pool.test.mjs
Normal 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
268
test/orders.test.mjs
Normal 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
125
test/plans.test.mjs
Normal 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
359
test/register-tx.test.mjs
Normal 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))));
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue