From 1a317007df77bb124f5a7b9373c3b1b86593d2cd Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Mon, 28 Sep 2026 01:21:47 +0200 Subject: [PATCH] feat(portal): the name's settings show what its subdomains serve, and let you change it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-name tab was a DNS record table: eight record types, of which the stack reads exactly one, and then only as a last resort when a name carries no content record. Everything else was signed, stored and read by nobody — furniture that looked like configuration. Meanwhile the thing a name owner actually has, a folder tree that already answers on matching subdomains, was not visible anywhere and could not be changed. The tab now leads with what answers today: the apex, every folder in the name's storage next to the subdomain it serves on, and which of those a rule overrides. Below it, the rules — point a subdomain at a different folder, redirect it, or switch it off. Classic DNS keeps its table, behind a disclosure that says plainly what those records do today and what they are waiting for. Nobody loses a record they published; they just stop being the first thing a name owner is asked to think about. The table gains a Host column, so records can belong to a subdomain rather than only the apex. Both halves are one signed manifest and one publish button — they always were one file, and two buttons for it only ever invited a half-saved state. Labels and values are separated in the generated rows so the words translate and the owner's own folder and URL do not. The signer accepted v1 manifests only, which would have rejected every publish from this screen. --- src/lib/registrar.js | 1996 +++++++++++++++++++++--------------------- 1 file changed, 999 insertions(+), 997 deletions(-) diff --git a/src/lib/registrar.js b/src/lib/registrar.js index 4583553..1e6cb2d 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -1,997 +1,999 @@ -// 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, - buildTldRegistrationTx, - buildUpdateTx, - buildUpdateTldTx, - buildPrepTx, - hasGenesisInput, - normalizeName, - normalizeTld, - displayName, - payloadBudget, - toWcTransactionObject, -} from "./register-tx.js"; -import { signTransaction, txidOfHex } from "./sign-tx.js"; -import { buildIndex, fetchTldMap, NETWORK } from "./resolver-web.js"; -import { REGISTRAR } from "../registrar-config.js"; -import { hexToBin, binToHex, decodeTransaction, lockingBytecodeToCashAddress, sha256, utf8ToBin, binToBase64 } from "@bitauth/libauth"; -import { signInputs, sourceOutputToJson, sourceOutputFromJson } from "./market-tx.js"; - -// 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 -// platform's cut. 90/10 today; adjustable per-TLD in future via a rate -// baked into the TLD certificate itself. -const TLD_OWNER_SHARE_NUM = 90n; -const TLD_OWNER_SHARE_DEN = 100n; - -/** - * Look up the current TLD owner's cashaddr by walking the TLD beacon. - * MVP: returns the ORIGINAL mint recipient — NFT transfers after mint aren't - * traced yet. Returns null when the TLD isn't registered or the mint script - * can't be decoded (in which case the caller should pay the full service - * fee to the platform address as a fallback). - */ -export async function findTldOwnerAddress(client, tld, prefix = REGISTRAR.addressPrefix) { - const map = await fetchTldMap(client); - const entry = map.get(String(tld).toLowerCase()); - if (!entry?.mintScriptHex) return null; - try { - const bytecode = hexToBin(entry.mintScriptHex); - const r = lockingBytecodeToCashAddress({ bytecode, prefix, tokenSupport: false }); - return typeof r === "string" ? null : r.address; - } catch { return null; } -} - -/** - * Split a service fee between the TLD owner (90%) and the platform (10%). - * Returns { tldOwnerFeeSats, platformFeeSats } — both bigints summing to - * exactly the input serviceFeeSats. Rounding favours the platform (the - * remainder from BigInt division stays on the platform side) so the sum is - * always exact even for odd sat counts. - */ -export function splitServiceFee(serviceFeeSats) { - const total = BigInt(serviceFeeSats ?? 0n); - const tldOwner = (total * TLD_OWNER_SHARE_NUM) / TLD_OWNER_SHARE_DEN; - const platform = total - tldOwner; - return { tldOwnerFeeSats: tldOwner, platformFeeSats: platform }; -} - -// URLs of "beacon-only" indexers (our own bns-indexer.js). They serve -// get_history + transaction.get for the name and TLD beacons but NOT -// listunspent for arbitrary scripthashes, so a wallet op that lands on one -// fails with "-32601 unsupported method: blockchain.scripthash.listunspent". -// Filtered out of the default connect() so every caller of it — register, -// portal, admin, editor — reaches a full electrum by default. -const BEACON_ONLY_URLS = NETWORK.beaconOnly; - -/** - * Open a connection to the chain. Caller must `.close()` it. - * - * By default the beacon-only indexers (silentmode.st/electrum, - * coinspectrum.duckdns.org:50011) are filtered out so wallet ops always land - * on a full electrum. For pure-name-resolution paths that only need - * `blockchain.scripthash.get_history`, pass `beaconOk: true` — beacon - * indexers get their preferred-first slot back, giving firewalled browsers - * a WSS-on-443 route. - */ -export async function connect({ electrum = REGISTRAR.electrum, WebSocket, beaconOk = false } = {}) { - const list = beaconOk ? electrum : electrum.filter((u) => !BEACON_ONLY_URLS.test(typeof u === "string" ? u : (u.url || ""))); - return connectAny(list, 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, - cosignUtxo = null, - cosignTokenAddress = null, -}) { - const coins = utxos ?? (await getUtxosForAddresses(client, [ownerAddress])); - // Revenue split: 90% of the service fee flows to whoever owns the TLD - // certificate for this name. If we can't identify the TLD owner (TLD not - // yet registered, gateway hiccup, decode failure), fall back to routing - // the entire fee to the platform — the buyer still pays the same amount. - const raw = normalizeName(name); - const dot = raw.indexOf("."); - const tld = dot < 0 ? "bch" : raw.slice(dot + 1); - const totalFee = BigInt(serviceFee.sats ?? 0n); - let tldOwnerAddress = null; - let tldOwnerFee = 0n; - let platformFee = totalFee; - if (totalFee > 0n) { - try { tldOwnerAddress = await findTldOwnerAddress(client, tld); } - catch { tldOwnerAddress = null; } - // Do not pay the TLD owner if THEY are the buyer (same address holds the - // TLD cert AND is signing this registration) — the "split" would be a - // no-op that just costs an extra output. - if (tldOwnerAddress && tldOwnerAddress !== ownerAddress) { - const split = splitServiceFee(totalFee); - tldOwnerFee = split.tldOwnerFeeSats; - platformFee = split.platformFeeSats; - } - } - return buildRegistrationTx({ - name, - ownerAddress, - ownerTokenAddress: toTokenAddress(ownerAddress), - records, - utxos: coins, - feeAddress: platformFee > 0n ? serviceFee.address : null, - feeSats: platformFee, - tldFeeAddress: tldOwnerFee > 0n ? tldOwnerAddress : null, - tldFeeSats: tldOwnerFee, - beaconAddress: REGISTRAR.beacon, - feeRate, - addressToLockingBytecode, - cosignUtxo, - cosignTokenAddress, - }); -} - -/** - * The TLD certificate UTXO held by this wallet, if any — needed to co-sign a - * registration under a private (hidden / cosign-policy) TLD the wallet owns. - */ -export function findTldCertificateUtxo(utxos, { tld, category }) { - const commitment = [...new TextEncoder().encode(String(tld).toLowerCase())] - .map((b) => b.toString(16).padStart(2, "0")).join(""); - return utxos.find((u) => u.token?.nft && (!category || u.token.category === category) && u.token.nft.commitment === commitment) ?? null; -} - -/** - * 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] - */ -// 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. -// Returns the (possibly replaced) coin list and the prep txid, if any. -async function ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress }) { - if (hasGenesisInput(utxos)) return { utxos, prepTxid: null }; - onProgress("preparing-wallet"); - const prep = buildPrepTx({ - ownerAddress: wallet.address, - utxos, - feeRate, - addressToLockingBytecode, - }); - const signedPrep = signTransaction(prep.transaction, prep.sourceOutputs, (h) => wallet.keyFor(h)); - const prepTxid = await broadcastTx(client, signedPrep.hex); - onProgress("prepared", { txid: prepTxid }); - // Spend the prep tx's outputs directly rather than waiting for the indexer. - const fresh = signedPrep.transaction.outputs.slice(0, 2).map((o, vout) => ({ - txid: prepTxid, - vout, - satoshis: o.valueSatoshis, - address: wallet.address, - lockingBytecode: addressToLockingBytecode(wallet.address), - })); - return { utxos: fresh, prepTxid }; -} - -/** - * Third-party registration under a `cosign`-policy TLD. Builds the complete - * REG with the TLD owner's certificate as the LAST input (re-issued to the - * owner's token address, which is the co-signature the index requires), - * signs only this wallet's inputs and returns a JSON-safe request for the - * gateway's /api/cosign queue. The owner finishes it with signCosignRequest. - * - * @param {object} o.tldCert the /api/tld-cert/ response: { owner, category, utxo } - */ -export async function buildCosignRequest(client, { - wallet, - name, - records = {}, - serviceFee = REGISTRAR.serviceFee, - feeRate = REGISTRAR.feeRate, - tldCert, - onProgress = () => {}, -}) { - if (!tldCert?.utxo?.token?.nft || !tldCert.owner) throw new Error("tldCert must be the /api/tld-cert response ({ owner, utxo })"); - 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"); - let prepTxid = null; - ({ utxos, prepTxid } = await ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress })); - - onProgress("building"); - const built = await quoteRegistration(client, { - name, - ownerAddress: wallet.address, - records, - utxos, - serviceFee, - feeRate, - cosignUtxo: { ...tldCert.utxo, address: tldCert.owner }, - cosignTokenAddress: toTokenAddress(tldCert.owner), - }); - const cosignInputIndex = built.transaction.inputs.length - 1; - - onProgress("signing"); - // The builder leaves a size placeholder in every unlocking script; blank - // the certificate input so the request carries a visibly unsigned slot. - // (BCH sighash never covers other inputs' scripts, so our signatures stay - // valid whatever the owner puts there.) - const draft = { ...built.transaction, inputs: built.transaction.inputs.map((inp, i) => (i === cosignInputIndex ? { ...inp, unlockingBytecode: new Uint8Array() } : inp)) }; - const signed = signInputs(draft, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [cosignInputIndex] }); - return { - name: built.name, - displayName: built.displayName, - category: built.category, - tx: signed.hex, - sourceOutputs: built.sourceOutputs.map(sourceOutputToJson), - cosignInputIndex, - costs: built.costs, - prepTxid, - }; -} - -/** - * TLD owner side of a co-sign request: sign the inputs this wallet holds the - * key for (the TLD certificate) and return the fully signed hex to broadcast. - * Refuses to sign unless every certificate input we sign is re-issued, with - * its satoshis, to the very same locking script — a request can never move - * the TLD certificate away from its owner. - */ -export function signCosignRequest(request, keyFor) { - const decoded = decodeTransaction(hexToBin(String(request.tx || ""))); - if (typeof decoded === "string") throw new Error("invalid transaction: " + decoded); - const sourceOutputs = (request.sourceOutputs || []).map(sourceOutputFromJson); - if (sourceOutputs.length !== decoded.inputs.length) throw new Error("sourceOutputs do not match the transaction inputs"); - const skip = []; - let signedInputs = 0; - decoded.inputs.forEach((_inp, i) => { - const so = sourceOutputs[i]; - if (!keyFor(binToHex(so.lockingBytecode))) { skip.push(i); return; } - if (so.token?.nft) { - const back = decoded.outputs.some((o) => o.token - && binToHex(o.token.category) === binToHex(so.token.category) - && binToHex(o.token.nft?.commitment ?? new Uint8Array()) === binToHex(so.token.nft.commitment ?? new Uint8Array()) - && binToHex(o.lockingBytecode) === binToHex(so.lockingBytecode) - && o.valueSatoshis >= so.valueSatoshis); - if (!back) throw new Error("refusing to sign: the request would not return your TLD certificate to you"); - } - signedInputs += 1; - }); - const out = signInputs(decoded, sourceOutputs, keyFor, { skip }); - return { hex: out.hex, txid: out.txid, signedInputs }; -} - -export async function registerWithBuiltInWallet(client, { - wallet, - name, - records = {}, - serviceFee = REGISTRAR.serviceFee, - feeRate = REGISTRAR.feeRate, - // { tld, category }: co-sign with the TLD certificate this wallet holds - // (private TLD the owner registers under). See buildRegistrationTx. - cosign = null, - 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"); - let cosignUtxo = null; - if (cosign) { - cosignUtxo = findTldCertificateUtxo(utxos, cosign); - if (!cosignUtxo) throw new Error(`this wallet does not hold the certificate for .${cosign.tld} — only the TLD owner can co-sign`); - } - - let prepTxid = null; - ({ utxos, prepTxid } = await ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress })); - - onProgress("building"); - const built = await quoteRegistration(client, { - name, - ownerAddress: wallet.address, - records, - utxos, - serviceFee, - feeRate, - cosignUtxo, - cosignTokenAddress: cosignUtxo ? toTokenAddress(wallet.address) : null, - }); - - 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, - }; -} - - -/** - * Register a TLD certificate on the TLD-registry beacon. Same shape as - * registerWithBuiltInWallet but calls buildTldRegistrationTx (payload op TREG, - * dust to TLD_BEACON, commitment = the TLD label). Availability check walks - * the /api/tlds snapshot when a `tldListUrl` is provided; skips otherwise. - * - * @param {object} o.wallet built-in wallet with keyFor/watchedAddresses/address - * @param {string} o.tld a bare TLD label, e.g. "vpn" (1–16 chars, [a-z0-9-]) - * @param {object} [o.records] TLD-cert records (policy, fee_bps, reserved, …) - * @param {object} [o.serviceFee] {address, sats} — the $5-in-sats fee output - * @param {string} [o.tldListUrl] endpoint to fetch existing TLDs from (e.g. - * https://navigate.st/api/tlds). Availability - * check is skipped if not provided. - */ -export async function registerTldWithBuiltInWallet(client, { - wallet, - tld, - records = {}, - serviceFee = { address: null, sats: 0n }, - feeRate = REGISTRAR.feeRate, - tldListUrl = null, - onProgress = () => {}, -}) { - const rawTld = normalizeTld(tld); - onProgress("checking-availability"); - if (tldListUrl) { - try { - const r = await fetch(tldListUrl); - if (r.ok) { - const j = await r.json(); - const taken = Array.isArray(j.tlds) && j.tlds.some((t) => (t.tld || t) === rawTld); - if (taken) throw new Error(`".${rawTld}" is already registered as a TLD`); - } - } catch (e) { - if (/already registered/.test(e.message)) throw e; - // network / API blip — proceed; on-chain "first TREG wins" rule handles - // collisions and the client-side check is a UX assist, not a gate. - } - } - - 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"); - - // Same genesis-input rule as name REGs: needs a vout-0 non-token UTXO. - 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 }); - 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 = buildTldRegistrationTx({ - tld: rawTld, - ownerAddress: wallet.address, - ownerTokenAddress: toTokenAddress(wallet.address), - changeAddress: wallet.address, - records, - utxos, - feeAddress: serviceFee.address || null, - feeSats: BigInt(serviceFee.sats ?? 0n), - 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("registered", { txid, category: built.category }); - return { - tld: built.tld, - category: built.category, - txid, - prepTxid, - ownerAddress: wallet.address, - certificateAddress: toTokenAddress(wallet.address), - costs: built.costs, - hex: signed.hex, - }; -} - -/** - * Register a TLD with an EXTERNAL wallet (WizardConnect). - * - * Same transaction shape as registerTldWithBuiltInWallet — same TREG payload, - * same TLD-beacon output, same service-fee output — only the signing transport - * differs. Genesis-input requirement is handled by prompting the connected - * wallet for a one-time prep signature if it can't front a vout-0 non-token - * coin already, mirroring registerWithExternalWallet's pattern. - * - * Does NOT accept `ownerAddress` because a TLD certificate is minted to - * whoever signs the mint — using the first address the connected wallet - * exposes keeps the same "you own what you signed for" invariant as the - * name-registration external path. - * - * @param {object} o.session a WalletSession (see connect/) - */ -export async function registerTldWithExternalWallet(client, { - session, - tld, - records = {}, - serviceFee = { address: null, sats: 0n }, - feeRate = REGISTRAR.feeRate, - tldListUrl = null, - onProgress = () => {}, -}) { - const rawTld = normalizeTld(tld); - onProgress("checking-availability"); - if (tldListUrl) { - try { - const r = await fetch(tldListUrl); - if (r.ok) { - const j = await r.json(); - const taken = Array.isArray(j.tlds) && j.tlds.some((t) => (t.tld || t) === rawTld); - if (taken) throw new Error(`".${rawTld}" is already registered as a TLD`); - } - } catch (e) { - if (/already registered/.test(e.message)) throw e; - // network / API blip — proceed; on-chain "first TREG wins" handles it. - } - } - - onProgress("reading-wallet"); - const addresses = await session.getAddresses(); - if (!addresses.length) throw new Error("the wallet exposed no addresses"); - const owner = addresses[0]; - - onProgress("loading-coins"); - let utxos = await getUtxosForAddresses(client, addresses); - if (utxos.filter((u) => !u.token).reduce((s, u) => s + u.satoshis, 0n) === 0n) { - throw new Error("the connected wallet has no spendable coins"); - } - - let prepTxid = null; - 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 TLD certificate", - }); - 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 = buildTldRegistrationTx({ - tld: rawTld, - ownerAddress: owner, - ownerTokenAddress: toTokenAddress(owner), - changeAddress: owner, - records, - utxos, - feeAddress: serviceFee.address || null, - feeSats: BigInt(serviceFee.sats ?? 0n), - feeRate, - addressToLockingBytecode, - }); - - onProgress("awaiting-approval"); - const signed = await session.signTransaction(built, { - userPrompt: `Register .${rawTld} — the TLD certificate is minted to your own address`, - }); - const txid = await landOnChain(client, session, signed, onProgress); - - onProgress("registered", { txid, category: built.category }); - return { - tld: built.tld, - category: built.category, - txid, - prepTxid, - ownerAddress: owner, - certificateAddress: toTokenAddress(owner), - costs: built.costs, - }; -} - -/** - * 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 }; -} - -/** - * 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); - 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 }; +// 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, + buildTldRegistrationTx, + buildUpdateTx, + buildUpdateTldTx, + buildPrepTx, + hasGenesisInput, + normalizeName, + normalizeTld, + displayName, + payloadBudget, + toWcTransactionObject, +} from "./register-tx.js"; +import { signTransaction, txidOfHex } from "./sign-tx.js"; +import { buildIndex, fetchTldMap, NETWORK } from "./resolver-web.js"; +import { REGISTRAR } from "../registrar-config.js"; +import { hexToBin, binToHex, decodeTransaction, lockingBytecodeToCashAddress, sha256, utf8ToBin, binToBase64 } from "@bitauth/libauth"; +import { signInputs, sourceOutputToJson, sourceOutputFromJson } from "./market-tx.js"; + +// 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 +// platform's cut. 90/10 today; adjustable per-TLD in future via a rate +// baked into the TLD certificate itself. +const TLD_OWNER_SHARE_NUM = 90n; +const TLD_OWNER_SHARE_DEN = 100n; + +/** + * Look up the current TLD owner's cashaddr by walking the TLD beacon. + * MVP: returns the ORIGINAL mint recipient — NFT transfers after mint aren't + * traced yet. Returns null when the TLD isn't registered or the mint script + * can't be decoded (in which case the caller should pay the full service + * fee to the platform address as a fallback). + */ +export async function findTldOwnerAddress(client, tld, prefix = REGISTRAR.addressPrefix) { + const map = await fetchTldMap(client); + const entry = map.get(String(tld).toLowerCase()); + if (!entry?.mintScriptHex) return null; + try { + const bytecode = hexToBin(entry.mintScriptHex); + const r = lockingBytecodeToCashAddress({ bytecode, prefix, tokenSupport: false }); + return typeof r === "string" ? null : r.address; + } catch { return null; } +} + +/** + * Split a service fee between the TLD owner (90%) and the platform (10%). + * Returns { tldOwnerFeeSats, platformFeeSats } — both bigints summing to + * exactly the input serviceFeeSats. Rounding favours the platform (the + * remainder from BigInt division stays on the platform side) so the sum is + * always exact even for odd sat counts. + */ +export function splitServiceFee(serviceFeeSats) { + const total = BigInt(serviceFeeSats ?? 0n); + const tldOwner = (total * TLD_OWNER_SHARE_NUM) / TLD_OWNER_SHARE_DEN; + const platform = total - tldOwner; + return { tldOwnerFeeSats: tldOwner, platformFeeSats: platform }; +} + +// URLs of "beacon-only" indexers (our own bns-indexer.js). They serve +// get_history + transaction.get for the name and TLD beacons but NOT +// listunspent for arbitrary scripthashes, so a wallet op that lands on one +// fails with "-32601 unsupported method: blockchain.scripthash.listunspent". +// Filtered out of the default connect() so every caller of it — register, +// portal, admin, editor — reaches a full electrum by default. +const BEACON_ONLY_URLS = NETWORK.beaconOnly; + +/** + * Open a connection to the chain. Caller must `.close()` it. + * + * By default the beacon-only indexers (silentmode.st/electrum, + * coinspectrum.duckdns.org:50011) are filtered out so wallet ops always land + * on a full electrum. For pure-name-resolution paths that only need + * `blockchain.scripthash.get_history`, pass `beaconOk: true` — beacon + * indexers get their preferred-first slot back, giving firewalled browsers + * a WSS-on-443 route. + */ +export async function connect({ electrum = REGISTRAR.electrum, WebSocket, beaconOk = false } = {}) { + const list = beaconOk ? electrum : electrum.filter((u) => !BEACON_ONLY_URLS.test(typeof u === "string" ? u : (u.url || ""))); + return connectAny(list, 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, + cosignUtxo = null, + cosignTokenAddress = null, +}) { + const coins = utxos ?? (await getUtxosForAddresses(client, [ownerAddress])); + // Revenue split: 90% of the service fee flows to whoever owns the TLD + // certificate for this name. If we can't identify the TLD owner (TLD not + // yet registered, gateway hiccup, decode failure), fall back to routing + // the entire fee to the platform — the buyer still pays the same amount. + const raw = normalizeName(name); + const dot = raw.indexOf("."); + const tld = dot < 0 ? "bch" : raw.slice(dot + 1); + const totalFee = BigInt(serviceFee.sats ?? 0n); + let tldOwnerAddress = null; + let tldOwnerFee = 0n; + let platformFee = totalFee; + if (totalFee > 0n) { + try { tldOwnerAddress = await findTldOwnerAddress(client, tld); } + catch { tldOwnerAddress = null; } + // Do not pay the TLD owner if THEY are the buyer (same address holds the + // TLD cert AND is signing this registration) — the "split" would be a + // no-op that just costs an extra output. + if (tldOwnerAddress && tldOwnerAddress !== ownerAddress) { + const split = splitServiceFee(totalFee); + tldOwnerFee = split.tldOwnerFeeSats; + platformFee = split.platformFeeSats; + } + } + return buildRegistrationTx({ + name, + ownerAddress, + ownerTokenAddress: toTokenAddress(ownerAddress), + records, + utxos: coins, + feeAddress: platformFee > 0n ? serviceFee.address : null, + feeSats: platformFee, + tldFeeAddress: tldOwnerFee > 0n ? tldOwnerAddress : null, + tldFeeSats: tldOwnerFee, + beaconAddress: REGISTRAR.beacon, + feeRate, + addressToLockingBytecode, + cosignUtxo, + cosignTokenAddress, + }); +} + +/** + * The TLD certificate UTXO held by this wallet, if any — needed to co-sign a + * registration under a private (hidden / cosign-policy) TLD the wallet owns. + */ +export function findTldCertificateUtxo(utxos, { tld, category }) { + const commitment = [...new TextEncoder().encode(String(tld).toLowerCase())] + .map((b) => b.toString(16).padStart(2, "0")).join(""); + return utxos.find((u) => u.token?.nft && (!category || u.token.category === category) && u.token.nft.commitment === commitment) ?? null; +} + +/** + * 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] + */ +// 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. +// Returns the (possibly replaced) coin list and the prep txid, if any. +async function ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress }) { + if (hasGenesisInput(utxos)) return { utxos, prepTxid: null }; + onProgress("preparing-wallet"); + const prep = buildPrepTx({ + ownerAddress: wallet.address, + utxos, + feeRate, + addressToLockingBytecode, + }); + const signedPrep = signTransaction(prep.transaction, prep.sourceOutputs, (h) => wallet.keyFor(h)); + const prepTxid = await broadcastTx(client, signedPrep.hex); + onProgress("prepared", { txid: prepTxid }); + // Spend the prep tx's outputs directly rather than waiting for the indexer. + const fresh = signedPrep.transaction.outputs.slice(0, 2).map((o, vout) => ({ + txid: prepTxid, + vout, + satoshis: o.valueSatoshis, + address: wallet.address, + lockingBytecode: addressToLockingBytecode(wallet.address), + })); + return { utxos: fresh, prepTxid }; +} + +/** + * Third-party registration under a `cosign`-policy TLD. Builds the complete + * REG with the TLD owner's certificate as the LAST input (re-issued to the + * owner's token address, which is the co-signature the index requires), + * signs only this wallet's inputs and returns a JSON-safe request for the + * gateway's /api/cosign queue. The owner finishes it with signCosignRequest. + * + * @param {object} o.tldCert the /api/tld-cert/ response: { owner, category, utxo } + */ +export async function buildCosignRequest(client, { + wallet, + name, + records = {}, + serviceFee = REGISTRAR.serviceFee, + feeRate = REGISTRAR.feeRate, + tldCert, + onProgress = () => {}, +}) { + if (!tldCert?.utxo?.token?.nft || !tldCert.owner) throw new Error("tldCert must be the /api/tld-cert response ({ owner, utxo })"); + 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"); + let prepTxid = null; + ({ utxos, prepTxid } = await ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress })); + + onProgress("building"); + const built = await quoteRegistration(client, { + name, + ownerAddress: wallet.address, + records, + utxos, + serviceFee, + feeRate, + cosignUtxo: { ...tldCert.utxo, address: tldCert.owner }, + cosignTokenAddress: toTokenAddress(tldCert.owner), + }); + const cosignInputIndex = built.transaction.inputs.length - 1; + + onProgress("signing"); + // The builder leaves a size placeholder in every unlocking script; blank + // the certificate input so the request carries a visibly unsigned slot. + // (BCH sighash never covers other inputs' scripts, so our signatures stay + // valid whatever the owner puts there.) + const draft = { ...built.transaction, inputs: built.transaction.inputs.map((inp, i) => (i === cosignInputIndex ? { ...inp, unlockingBytecode: new Uint8Array() } : inp)) }; + const signed = signInputs(draft, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [cosignInputIndex] }); + return { + name: built.name, + displayName: built.displayName, + category: built.category, + tx: signed.hex, + sourceOutputs: built.sourceOutputs.map(sourceOutputToJson), + cosignInputIndex, + costs: built.costs, + prepTxid, + }; +} + +/** + * TLD owner side of a co-sign request: sign the inputs this wallet holds the + * key for (the TLD certificate) and return the fully signed hex to broadcast. + * Refuses to sign unless every certificate input we sign is re-issued, with + * its satoshis, to the very same locking script — a request can never move + * the TLD certificate away from its owner. + */ +export function signCosignRequest(request, keyFor) { + const decoded = decodeTransaction(hexToBin(String(request.tx || ""))); + if (typeof decoded === "string") throw new Error("invalid transaction: " + decoded); + const sourceOutputs = (request.sourceOutputs || []).map(sourceOutputFromJson); + if (sourceOutputs.length !== decoded.inputs.length) throw new Error("sourceOutputs do not match the transaction inputs"); + const skip = []; + let signedInputs = 0; + decoded.inputs.forEach((_inp, i) => { + const so = sourceOutputs[i]; + if (!keyFor(binToHex(so.lockingBytecode))) { skip.push(i); return; } + if (so.token?.nft) { + const back = decoded.outputs.some((o) => o.token + && binToHex(o.token.category) === binToHex(so.token.category) + && binToHex(o.token.nft?.commitment ?? new Uint8Array()) === binToHex(so.token.nft.commitment ?? new Uint8Array()) + && binToHex(o.lockingBytecode) === binToHex(so.lockingBytecode) + && o.valueSatoshis >= so.valueSatoshis); + if (!back) throw new Error("refusing to sign: the request would not return your TLD certificate to you"); + } + signedInputs += 1; + }); + const out = signInputs(decoded, sourceOutputs, keyFor, { skip }); + return { hex: out.hex, txid: out.txid, signedInputs }; +} + +export async function registerWithBuiltInWallet(client, { + wallet, + name, + records = {}, + serviceFee = REGISTRAR.serviceFee, + feeRate = REGISTRAR.feeRate, + // { tld, category }: co-sign with the TLD certificate this wallet holds + // (private TLD the owner registers under). See buildRegistrationTx. + cosign = null, + 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"); + let cosignUtxo = null; + if (cosign) { + cosignUtxo = findTldCertificateUtxo(utxos, cosign); + if (!cosignUtxo) throw new Error(`this wallet does not hold the certificate for .${cosign.tld} — only the TLD owner can co-sign`); + } + + let prepTxid = null; + ({ utxos, prepTxid } = await ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress })); + + onProgress("building"); + const built = await quoteRegistration(client, { + name, + ownerAddress: wallet.address, + records, + utxos, + serviceFee, + feeRate, + cosignUtxo, + cosignTokenAddress: cosignUtxo ? toTokenAddress(wallet.address) : null, + }); + + 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, + }; +} + + +/** + * Register a TLD certificate on the TLD-registry beacon. Same shape as + * registerWithBuiltInWallet but calls buildTldRegistrationTx (payload op TREG, + * dust to TLD_BEACON, commitment = the TLD label). Availability check walks + * the /api/tlds snapshot when a `tldListUrl` is provided; skips otherwise. + * + * @param {object} o.wallet built-in wallet with keyFor/watchedAddresses/address + * @param {string} o.tld a bare TLD label, e.g. "vpn" (1–16 chars, [a-z0-9-]) + * @param {object} [o.records] TLD-cert records (policy, fee_bps, reserved, …) + * @param {object} [o.serviceFee] {address, sats} — the $5-in-sats fee output + * @param {string} [o.tldListUrl] endpoint to fetch existing TLDs from (e.g. + * https://navigate.st/api/tlds). Availability + * check is skipped if not provided. + */ +export async function registerTldWithBuiltInWallet(client, { + wallet, + tld, + records = {}, + serviceFee = { address: null, sats: 0n }, + feeRate = REGISTRAR.feeRate, + tldListUrl = null, + onProgress = () => {}, +}) { + const rawTld = normalizeTld(tld); + onProgress("checking-availability"); + if (tldListUrl) { + try { + const r = await fetch(tldListUrl); + if (r.ok) { + const j = await r.json(); + const taken = Array.isArray(j.tlds) && j.tlds.some((t) => (t.tld || t) === rawTld); + if (taken) throw new Error(`".${rawTld}" is already registered as a TLD`); + } + } catch (e) { + if (/already registered/.test(e.message)) throw e; + // network / API blip — proceed; on-chain "first TREG wins" rule handles + // collisions and the client-side check is a UX assist, not a gate. + } + } + + 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"); + + // Same genesis-input rule as name REGs: needs a vout-0 non-token UTXO. + 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 }); + 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 = buildTldRegistrationTx({ + tld: rawTld, + ownerAddress: wallet.address, + ownerTokenAddress: toTokenAddress(wallet.address), + changeAddress: wallet.address, + records, + utxos, + feeAddress: serviceFee.address || null, + feeSats: BigInt(serviceFee.sats ?? 0n), + 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("registered", { txid, category: built.category }); + return { + tld: built.tld, + category: built.category, + txid, + prepTxid, + ownerAddress: wallet.address, + certificateAddress: toTokenAddress(wallet.address), + costs: built.costs, + hex: signed.hex, + }; +} + +/** + * Register a TLD with an EXTERNAL wallet (WizardConnect). + * + * Same transaction shape as registerTldWithBuiltInWallet — same TREG payload, + * same TLD-beacon output, same service-fee output — only the signing transport + * differs. Genesis-input requirement is handled by prompting the connected + * wallet for a one-time prep signature if it can't front a vout-0 non-token + * coin already, mirroring registerWithExternalWallet's pattern. + * + * Does NOT accept `ownerAddress` because a TLD certificate is minted to + * whoever signs the mint — using the first address the connected wallet + * exposes keeps the same "you own what you signed for" invariant as the + * name-registration external path. + * + * @param {object} o.session a WalletSession (see connect/) + */ +export async function registerTldWithExternalWallet(client, { + session, + tld, + records = {}, + serviceFee = { address: null, sats: 0n }, + feeRate = REGISTRAR.feeRate, + tldListUrl = null, + onProgress = () => {}, +}) { + const rawTld = normalizeTld(tld); + onProgress("checking-availability"); + if (tldListUrl) { + try { + const r = await fetch(tldListUrl); + if (r.ok) { + const j = await r.json(); + const taken = Array.isArray(j.tlds) && j.tlds.some((t) => (t.tld || t) === rawTld); + if (taken) throw new Error(`".${rawTld}" is already registered as a TLD`); + } + } catch (e) { + if (/already registered/.test(e.message)) throw e; + // network / API blip — proceed; on-chain "first TREG wins" handles it. + } + } + + onProgress("reading-wallet"); + const addresses = await session.getAddresses(); + if (!addresses.length) throw new Error("the wallet exposed no addresses"); + const owner = addresses[0]; + + onProgress("loading-coins"); + let utxos = await getUtxosForAddresses(client, addresses); + if (utxos.filter((u) => !u.token).reduce((s, u) => s + u.satoshis, 0n) === 0n) { + throw new Error("the connected wallet has no spendable coins"); + } + + let prepTxid = null; + 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 TLD certificate", + }); + 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 = buildTldRegistrationTx({ + tld: rawTld, + ownerAddress: owner, + ownerTokenAddress: toTokenAddress(owner), + changeAddress: owner, + records, + utxos, + feeAddress: serviceFee.address || null, + feeSats: BigInt(serviceFee.sats ?? 0n), + feeRate, + addressToLockingBytecode, + }); + + onProgress("awaiting-approval"); + const signed = await session.signTransaction(built, { + userPrompt: `Register .${rawTld} — the TLD certificate is minted to your own address`, + }); + const txid = await landOnChain(client, session, signed, onProgress); + + onProgress("registered", { txid, category: built.category }); + return { + tld: built.tld, + category: built.category, + txid, + prepTxid, + ownerAddress: owner, + certificateAddress: toTokenAddress(owner), + costs: built.costs, + }; +} + +/** + * 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 }; +} + +/** + * 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, hosts?, 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) { + // v1 carried apex `dns` only; v2 adds `hosts`. Both sign identically, so the + // signer just has to not refuse the newer one. + if (!manifest || (manifest.v !== 1 && manifest.v !== 2)) throw new Error("manifest.v must be 1 or 2"); + 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); + 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 };