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 };