diff --git a/src/lib/registrar.js b/src/lib/registrar.js index c3a881d..0cc8ec5 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -14,10 +14,12 @@ import { } from "./chain.js"; import { buildRegistrationTx, + buildTldRegistrationTx, buildUpdateTx, buildPrepTx, hasGenesisInput, normalizeName, + normalizeTld, displayName, payloadBudget, toWcTransactionObject, @@ -26,9 +28,27 @@ import { signTransaction, txidOfHex } from "./sign-tx.js"; import { buildIndex } from "./resolver-web.js"; import { REGISTRAR } from "../registrar-config.js"; -/** Open a connection to the chain. Caller must `.close()` it. */ -export async function connect({ electrum = REGISTRAR.electrum, WebSocket } = {}) { - return connectAny(electrum, WebSocket); +// 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 = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; + +/** + * 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. */ @@ -164,6 +184,115 @@ export async function registerWithBuiltInWallet(client, { } +/** + * 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, + }; +} + /** * Mint a name certificate on a buyer's behalf, from the registrar's own coins. *