From d07fece6c9465693c2ee45479af89a1a030ff3e4 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 00:18:19 +0200 Subject: [PATCH] feat(argus/registrar): TLD-registration TREG builder (per-TLD certificates) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New public API in register-tx.js: - TLD_BEACON constant (per Decentralized.DNS/DESIGN-tld-registry.md §3); keep in step with Argus/src/config.js:TLD_BEACON and the indexer's BNS_TLD_BEACON_SCRIPTHASH — all three must agree, or clients disagree about which TLDs exist. - normalizeTld(input) — validates a bare TLD label (1–16 chars, [a-z0-9-]), matches bns.js's normalizeTld. - buildTldRegistrationTx({...}) — mints a per-TLD certificate onto the TLD beacon. Same shape as buildRegistrationTx (genesis-input rule, dust accounting, change-at-vout-0) but the payload op is "TREG", the commitment is the TLD label, and dust flows to TLD_BEACON instead of the name beacon. register-entry.js re-exports registerTldWithBuiltInWallet so the in-browser registrar can drive TLD mints alongside name mints. --- src/lib/register-tx.js | 140 ++++++++++++++++++++++++++++++++++++++ src/web/register-entry.js | 4 ++ 2 files changed, 144 insertions(+) diff --git a/src/lib/register-tx.js b/src/lib/register-tx.js index 172e1a1..d3e146a 100644 --- a/src/lib/register-tx.js +++ b/src/lib/register-tx.js @@ -42,6 +42,11 @@ import { export const PREFIX = "BNS1"; export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"; +// TLD-registry beacon (Decentralized.DNS/DESIGN-tld-registry.md §3). Keep in +// step with Argus/src/config.js:TLD_BEACON and the indexer's default +// BNS_TLD_BEACON_SCRIPTHASH — the three must agree, or clients disagree +// about which TLD certificates exist. +export const TLD_BEACON = "bchtest:qzc4c76vtw9nd49g6x8nmvc0jz62uvk4dc7hmpmerh"; export const BEACON_DUST_SAT = 600n; export const CERTIFICATE_DUST_SAT = 1000n; // token outputs need more than plain dust export const P2PKH_DUST_SAT = 546n; @@ -51,6 +56,16 @@ export const MAX_PAYLOAD_BYTES = 200; const LABEL_RE = /^[a-z0-9-]{1,32}$/; const TLD_RE = /^[a-z0-9-]{1,16}$/; +// Validate a TLD label — same rule as bns.js's normalizeTld. Accepts the +// bare label (no dots), 1–16 chars, [a-z0-9-]. Lowercases first. +export function normalizeTld(input) { + const tld = String(input).toLowerCase().trim().replace(/^\.+/, "").replace(/\.+$/, ""); + if (!TLD_RE.test(tld)) { + throw new Error(`invalid TLD "${input}" (allowed: [a-z0-9-]{1,16})`); + } + return tld; +} + // A signed P2PKH unlocking script: push(64-byte schnorr sig + sighash byte) // then push(33-byte compressed pubkey) = 1+65+1+33 = 100 bytes. const P2PKH_UNLOCK_BYTES = 100; @@ -363,6 +378,131 @@ export function buildRegistrationTx({ throw new Error("could not converge on a fee — please retry"); } +/** + * Build a TLD-registry TREG transaction — mint a per-TLD certificate onto the + * TLD beacon. Same shape as buildRegistrationTx (genesis-input rule, dust + * accounting, change-at-vout-0) but the payload's op is "TREG", the commitment + * is the TLD label, and dust flows to TLD_BEACON instead of the name beacon. + * See Decentralized.DNS/DESIGN-tld-registry.md §3. + */ +export function buildTldRegistrationTx({ + tld, + ownerAddress, + ownerTokenAddress, + changeAddress, + records = {}, + utxos, + feeAddress = null, + feeSats = 0n, + beaconAddress = TLD_BEACON, + feeRate = 1, + addressToLockingBytecode, +}) { + if (typeof addressToLockingBytecode !== "function") { + throw new Error("addressToLockingBytecode must be injected (see chain.js)"); + } + const rawTld = normalizeTld(tld); + const payload = encodePayload("TREG", rawTld, records); + const serviceFee = BigInt(feeSats ?? 0n); + if (serviceFee > 0n && !feeAddress) throw new Error("feeSats set but feeAddress missing"); + + const changeLock = addressToLockingBytecode(changeAddress ?? ownerAddress); + const certLock = addressToLockingBytecode(ownerTokenAddress ?? ownerAddress); + const beaconLock = addressToLockingBytecode(beaconAddress); + const feeLock = serviceFee > 0n ? addressToLockingBytecode(feeAddress) : null; + + const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT + serviceFee; + + let selection = selectInputs(utxos, fixedOut + 300n); + let chainFee = 0n; + let change = 0n; + let withChange = true; + + for (let round = 0; round < 6; round++) { + const { inputs, totalSats } = selection; + const genesisUtxo = inputs[0]; + const category = hexToBin(genesisUtxo.txid); + + const build = (includeChange, changeSats) => { + const outputs = []; + if (includeChange) outputs.push({ lockingBytecode: changeLock, valueSatoshis: changeSats }); + outputs.push({ + lockingBytecode: certLock, + valueSatoshis: CERTIFICATE_DUST_SAT, + token: { + amount: 0n, + category, + nft: { capability: "none", commitment: utf8ToBin(rawTld) }, + }, + }); + outputs.push({ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }); + outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT }); + if (feeLock) outputs.push({ lockingBytecode: feeLock, valueSatoshis: serviceFee }); + return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs }; + }; + + const probe = build(true, 1_000_000n); + const size = measure(probe); + chainFee = BigInt(Math.ceil(size * feeRate)); + const need = fixedOut + chainFee; + + if (totalSats < need) { + const grown = selectInputs(utxos, need + 200n); + if (grown.inputs.length > inputs.length) { selection = grown; continue; } + throw new InsufficientFundsError( + `not enough funds: need ${need} sat, have ${totalSats} sat`, + { needSats: need, haveSats: totalSats }, + ); + } + + change = totalSats - need; + withChange = change >= P2PKH_DUST_SAT; + if (!withChange) { + const noChange = build(false, 0n); + chainFee = totalSats - fixedOut; + const minFee = BigInt(Math.ceil(measure(noChange) * feeRate)); + if (chainFee < minFee) { + const grown = selectInputs(utxos, fixedOut + minFee + P2PKH_DUST_SAT + 200n); + if (grown.inputs.length > inputs.length) { selection = grown; continue; } + throw new InsufficientFundsError( + `not enough funds: need ${fixedOut + minFee} sat, have ${totalSats} sat`, + { needSats: fixedOut + minFee, haveSats: totalSats }, + ); + } + change = 0n; + } + + const transaction = build(withChange, change); + const outputMap = {}; + let i = 0; + if (withChange) outputMap.change = i++; + outputMap.certificate = i++; + outputMap.opReturn = i++; + outputMap.beacon = i++; + if (feeLock) outputMap.serviceFee = i++; + + return { + tld: rawTld, + category: binToHex(category), + transaction, + sourceOutputs: inputs.map(toSourceOutput), + inputUtxos: inputs, + outputMap, + payload, + costs: { + certificateDustSats: CERTIFICATE_DUST_SAT, + beaconDustSats: BEACON_DUST_SAT, + serviceFeeSats: serviceFee, + chainFeeSats: chainFee, + changeSats: change, + totalInputSats: totalSats, + netCostSats: serviceFee + chainFee + BEACON_DUST_SAT, + }, + }; + } + throw new Error("could not converge on a fee — please retry"); +} + /** * Build a BNS1 UPD transaction: proves ownership by moving the name's * certificate back to the owner, and republishes the records. diff --git a/src/web/register-entry.js b/src/web/register-entry.js index fea8782..74a4c4d 100644 --- a/src/web/register-entry.js +++ b/src/web/register-entry.js @@ -11,6 +11,7 @@ export { quoteRegistration, registerWithBuiltInWallet, registerWithExternalWallet, + registerTldWithBuiltInWallet, setRecordsWithBuiltInWallet, setRecordsWithExternalWallet, findCertificateUtxo, @@ -19,13 +20,16 @@ export { export { REGISTRAR, priceSummary } from "../registrar-config.js"; export { buildRegistrationTx, + buildTldRegistrationTx, buildUpdateTx, buildPrepTx, hasGenesisInput, toWcTransactionObject, normalizeName, + normalizeTld, displayName, payloadBudget, + TLD_BEACON, NoGenesisInputError, InsufficientFundsError, } from "../lib/register-tx.js";