diff --git a/src/lib/registrar.js b/src/lib/registrar.js index ac43c56..f5e0525 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -16,6 +16,7 @@ import { buildRegistrationTx, buildTldRegistrationTx, buildUpdateTx, + buildUpdateTldTx, buildPrepTx, hasGenesisInput, normalizeName, @@ -27,7 +28,7 @@ import { import { signTransaction, txidOfHex } from "./sign-tx.js"; import { buildIndex, fetchTldMap } from "./resolver-web.js"; import { REGISTRAR } from "../registrar-config.js"; -import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth"; +import { hexToBin, lockingBytecodeToCashAddress, sha256, utf8ToBin, binToBase64 } from "@bitauth/libauth"; // Revenue split: percentage of the name-registration service fee that flows // to the TLD's owner (whoever holds the TLD's on-chain NFT). The rest is the @@ -778,6 +779,96 @@ export async function setRecordsWithExternalWallet(client, { return { name: built.name, displayName: built.displayName, category: built.category, txid, records, costs: built.costs }; } +/** + * Set records on a TLD certificate (TUPD) using the built-in wallet. Same + * shape as setRecordsWithBuiltInWallet but for TREG NFTs — takes a TLD label + * and the TREG's category, finds the certificate UTXO, and broadcasts a TUPD + * that carries the new records. The TREG NFT is re-outputted to the same + * address so ownership doesn't change. + */ +export async function setTldRecordsWithBuiltInWallet(client, { + wallet, + tld, + records, + category, + feeRate = REGISTRAR.feeRate, + onProgress = () => {}, +}) { + if (!category) throw new Error("setTldRecords requires the TLD certificate's category id"); + onProgress("loading-coins"); + const utxos = await getUtxosForAddresses(client, wallet.watchedAddresses); + const certificateUtxo = utxos.find( + (u) => u.token?.category === category && u.token?.nft, + ); + if (!certificateUtxo) { + throw new Error( + `this wallet does not hold the TLD certificate for ".${tld}" — only the owner can change its policy`, + ); + } + + onProgress("building"); + const built = buildUpdateTldTx({ + tld, + ownerAddress: wallet.address, + ownerTokenAddress: toTokenAddress(wallet.address), + records, + certificateUtxo, + utxos: utxos.filter((u) => !u.token), + 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 { tld: built.tld, displayName: built.displayName, category: built.category, txid, records, costs: built.costs }; +} + +/** + * Compose a signed BNS records manifest v1. Canonicalises the payload + * (sorted keys, no whitespace, nulls omitted — matches the gateway's + * canonicalize() exactly), sha256s the bytes, signs with the wallet's + * primary key, and returns the manifest with the base64 `sig` field + * embedded. Ready to POST at /api/records/. Zero chain writes. + * + * Contract: caller supplies { v, name, seq, updated_at, dns, meta? }. + * `seq` MUST be strictly greater than the previous manifest's seq; + * callers usually GET /api/records/, read seq, add 1. + * + * See Decentralized.DNS/DESIGN-signed-records-manifest.md. + */ +export async function signRecordsManifest(wallet, manifest) { + if (!manifest || manifest.v !== 1) throw new Error("manifest.v must be 1"); + if (!manifest.name) throw new Error("manifest.name is required"); + if (typeof manifest.seq !== "number" || manifest.seq < 0) throw new Error("manifest.seq must be a non-negative integer"); + const withoutSig = { ...manifest }; + delete withoutSig.sig; + const canonical = JSON.stringify(_canonicalize(withoutSig)); + const digest = sha256.hash(utf8ToBin(canonical)); + const sigBytes = wallet.signMessage(digest); + return { ...manifest, sig: binToBase64(sigBytes) }; +} + +// Deep-canonicalise: sorted keys at every level, drop nulls, arrays keep +// order. Kept private — the exported surface is signRecordsManifest. +function _canonicalize(x) { + if (x === null || x === undefined) return undefined; + if (Array.isArray(x)) return x.map(_canonicalize).filter((v) => v !== undefined); + if (typeof x === "object") { + const out = {}; + for (const k of Object.keys(x).sort()) { + const v = _canonicalize(x[k]); + if (v !== undefined) out[k] = v; + } + return out; + } + return x; +} + /** Total spendable (non-certificate) balance across the wallet's addresses. */ export async function getBalance(client, addresses) { const utxos = await getUtxosForAddresses(client, addresses); diff --git a/src/lib/wallet-web.js b/src/lib/wallet-web.js index e9e2c8c..1a939ae 100644 --- a/src/lib/wallet-web.js +++ b/src/lib/wallet-web.js @@ -51,25 +51,34 @@ function encodeAddress(publicKey, prefix, tokenSupport) { } export class BuiltInWallet { - /** @param {string} mnemonic @param {string} prefix */ - constructor(mnemonic, seed, prefix = CHIPNET_PREFIX) { + /** + * @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) { 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 = CHIPNET_PREFIX) { + static async create(prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) { const mnemonic = generateBip39Mnemonic(); - return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix); + return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix, accountPath); } - static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX) { + static async fromMnemonic(mnemonic, prefix = CHIPNET_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); + return new BuiltInWallet(clean, seed, prefix, accountPath); } _derive() { @@ -77,7 +86,7 @@ export class BuiltInWallet { 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 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); @@ -115,6 +124,39 @@ export class BuiltInWallet { 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) {