feat(argus/registrar): TLD-registration TREG builder (per-TLD certificates)

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.
This commit is contained in:
Local Dev 2026-09-07 00:18:19 +02:00
parent 408f965629
commit d07fece6c9
2 changed files with 144 additions and 0 deletions

View file

@ -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.

View file

@ -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";