argus/src/lib/wallet-web.js
Local Dev 53428a605f bns: one network table instead of chipnet constants copied into fifteen places
Every client hard-coded the chipnet beacons, address prefix and electrum
servers on its own: resolver, registrar config, wallets, gateway, indexer,
mirror scripts, the browser bundle and the mobile Java. A mainnet launch would
have meant finding all of them and hoping none was missed.

The table now lives in resolver-web.js, the one file every client already
shares, so it stays a single-file drop-in. BNS_NETWORK selects the record;
unset means chipnet, so nothing changes today: the live index resolves the
same 60 names and 20 TLDs, the 67 offline tests pass, and the dashboard,
market and studio load the same values through BNS.NETWORK.

The mainnet record carries the verified public servers, the prefix and its
own Sia bucket, but its beacons, start height and operator address are
deliberately null: requireBeacons() refuses to scan until they are pinned in
the order ROADMAP-MAINNET.md §6 requires. Bns.java reads a generated
BnsNetwork.java so the phone cannot drift from the desktop clients.
NETWORK-CONFIG.md records what reads the table and what a launch still pins.
2026-09-27 21:18:37 +02:00

233 lines
8.5 KiB
JavaScript

// 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";
import { NETWORK } from "./resolver-web.js";
export const CHIPNET_PREFIX = "bchtest";
export const MAINNET_PREFIX = "bitcoincash";
// Prefix of the active network (BNS_NETWORK; chipnet unless set).
export const DEFAULT_PREFIX = NETWORK.prefix;
// 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 {Uint8Array} seed
* @param {string} prefix
* @param {string} accountPath BIP44 account path (default: BCH `m/44'/145'/0'`).
* Override with e.g. `m/44'/0'/0'` for operator wallets that were derived
* on the BTC coin type, so BuiltInWallet can drive keys minted from the
* CLI/mainnet-js path too.
*/
constructor(mnemonic, seed, prefix = DEFAULT_PREFIX, accountPath = ACCOUNT_PATH) {
this.mnemonic = mnemonic;
this.prefix = prefix;
this.accountPath = accountPath;
this._root = deriveHdPrivateNodeFromSeed(seed);
this._keys = new Map(); // lockingBytecodeHex -> {privateKey, publicKey, path}
this._derive();
}
static async create(prefix = DEFAULT_PREFIX, accountPath = ACCOUNT_PATH) {
const mnemonic = generateBip39Mnemonic();
return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix, accountPath);
}
static async fromMnemonic(mnemonic, prefix = DEFAULT_PREFIX, accountPath = ACCOUNT_PATH) {
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, accountPath);
}
_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 = `${this.accountPath}/${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;
}
/**
* Sign a 32-byte digest with the key for `address` (default: the wallet's
* primary receive address) and return a 65-byte BCH message signature:
* [recovery flag, r|s]
* flag = 27 + recovery + 4 (compressed pubkey — standard)
*
* Used by the signed-records-manifest flow: the caller sha256s the
* canonical manifest bytes, hands the digest here, gets back a signature
* the gateway's POST endpoint verifies against the on-chain NFT owner.
* See DESIGN-signed-records-manifest.md.
*/
signMessage(digest, address) {
if (!(digest instanceof Uint8Array) || digest.length !== 32) {
throw new Error("signMessage expects a 32-byte Uint8Array digest");
}
const addr = address || this.address;
const entry = [...this.receive, ...this.change].find((k) => k.address === addr);
if (!entry) throw new Error(`no key for address ${addr}`);
const result = secp256k1.signMessageHashRecoverableCompact(entry.privateKey, digest);
if (typeof result === "string") throw new Error(`signMessage failed: ${result}`);
const { signature } = result;
// libauth names the field recoveryId; older builds used recovery. A wrong
// read here silently produced flag byte 0 and half of all signatures
// failed verification.
const recovery = result.recoveryId ?? result.recovery;
if (typeof recovery !== "number") throw new Error("signMessage: no recovery id in signature result");
const flag = 27 + 4 + recovery; // 4 = "public key was compressed"
const bytes = new Uint8Array(65);
bytes[0] = flag;
bytes.set(signature, 1);
return bytes;
}
// ---------- 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 ?? DEFAULT_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 };