2026-07-29 13:54:34 +02:00
|
|
|
// 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 {
|
2026-09-17 01:40:10 +02:00
|
|
|
/**
|
|
|
|
|
* @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 = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
|
2026-07-29 13:54:34 +02:00
|
|
|
this.mnemonic = mnemonic;
|
|
|
|
|
this.prefix = prefix;
|
2026-09-17 01:40:10 +02:00
|
|
|
this.accountPath = accountPath;
|
2026-07-29 13:54:34 +02:00
|
|
|
this._root = deriveHdPrivateNodeFromSeed(seed);
|
|
|
|
|
this._keys = new Map(); // lockingBytecodeHex -> {privateKey, publicKey, path}
|
|
|
|
|
this._derive();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 01:40:10 +02:00
|
|
|
static async create(prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
|
2026-07-29 13:54:34 +02:00
|
|
|
const mnemonic = generateBip39Mnemonic();
|
2026-09-17 01:40:10 +02:00
|
|
|
return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix, accountPath);
|
2026-07-29 13:54:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-17 01:40:10 +02:00
|
|
|
static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
|
2026-07-29 13:54:34 +02:00
|
|
|
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}`);
|
2026-09-17 01:40:10 +02:00
|
|
|
return new BuiltInWallet(clean, seed, prefix, accountPath);
|
2026-07-29 13:54:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_derive() {
|
|
|
|
|
this.receive = [];
|
|
|
|
|
this.change = [];
|
|
|
|
|
for (const [branch, list] of [[0, this.receive], [1, this.change]]) {
|
|
|
|
|
for (let i = 0; i < ADDRESS_WINDOW; i++) {
|
2026-09-17 01:40:10 +02:00
|
|
|
const path = `${this.accountPath}/${branch}/${i}`;
|
2026-07-29 13:54:34 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 01:40:10 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 13:54:34 +02:00
|
|
|
// ---------- 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 };
|