registrar: build and send a release, and keep registration closed until it confirms

buildReleaseTx spends the certificate and writes no token output: the
omission is the burn. The certificate's own dust pays the beacon and the fee,
so a release needs no other coin.

releaseWithBuiltInWallet checks whether the certificate sits in a beacon
transaction. One that arrived by a plain transfer cannot be proven burned
from the beacon history, so the flow publishes an UPD first and releases its
output. Both paths ran on chipnet, and a released name was registered again
after its release confirmed.

checkAvailability now reports a pending release as not available: a REG
broadcast while the release is unconfirmed could be mined beside it and be
void, and the buyer would have paid for nothing.
This commit is contained in:
Local Dev 2026-10-04 01:55:14 +02:00
parent 067771cf7f
commit fcca514c5a
4 changed files with 275 additions and 4 deletions

View file

@ -653,6 +653,113 @@ export function buildUpdateTx({
throw new Error("could not converge on a fee — please retry");
}
/**
* Build a BNS1 REL transaction: gives a name back to the pool by burning its
* certificate (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md).
*
* The certificate is an input and NO output carries its category — that
* omission is the burn. The certificate's own dust normally covers the beacon
* output and the fee, so `utxos` may be empty; plain coins are added only if
* it does not.
*
* The caller must make sure the certificate UTXO was created by a beacon
* transaction (a REG or an UPD). Resolvers prove the burn against the beacon
* history, so a certificate that arrived by a plain transfer has to be moved
* once with an UPD first — registrar.js releaseWithBuiltInWallet does that.
* @param {object} o.certificateUtxo the UTXO holding the certificate
*/
export function buildReleaseTx({
name,
ownerAddress,
certificateUtxo,
utxos = [],
beaconAddress = BEACON,
feeRate = 1,
addressToLockingBytecode,
}) {
if (typeof addressToLockingBytecode !== "function") {
throw new Error("addressToLockingBytecode must be injected (see chain.js)");
}
const rawName = normalizeName(name);
if (!certificateUtxo?.token?.nft) throw new Error("certificateUtxo does not carry a certificate");
const expected = binToHex(utf8ToBin(rawName));
if (certificateUtxo.token.nft.commitment !== expected) {
throw new Error(
`certificate commitment mismatch: holds "${binToUtf8(hexToBin(certificateUtxo.token.nft.commitment))}", ` +
`asked to release "${rawName}"`,
);
}
const payload = encodePayload("REL", rawName, {});
const ownerLock = addressToLockingBytecode(ownerAddress);
const beaconLock = addressToLockingBytecode(beaconAddress);
const fixedOut = BEACON_DUST_SAT;
let funding = { inputs: [], totalSats: 0n };
let chainFee = 0n;
let change = 0n;
let withChange = true;
for (let round = 0; round < 6; round++) {
const inputs = [certificateUtxo, ...funding.inputs];
const totalSats = certificateUtxo.satoshis + funding.totalSats;
// No output here carries a token: the certificate is not recreated.
const build = (includeChange, changeSats) => {
const outputs = [];
if (includeChange) outputs.push({ lockingBytecode: ownerLock, valueSatoshis: changeSats });
outputs.push({ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n });
outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT });
return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs };
};
// Fee for the transaction as it will actually be: try without change first
// (the common case — the certificate's dust pays for everything).
const feeNoChange = BigInt(Math.ceil(measure(build(false, 0n)) * feeRate));
const feeWithChange = BigInt(Math.ceil(measure(build(true, 1_000_000n)) * feeRate));
if (totalSats < fixedOut + feeNoChange) {
const grown = selectInputs(utxos, fixedOut + feeWithChange + 200n, { requireGenesis: false });
if (grown.inputs.length > funding.inputs.length) { funding = grown; continue; }
throw new InsufficientFundsError(
`not enough funds: need ${fixedOut + feeNoChange} sat, have ${totalSats} sat`,
{ needSats: fixedOut + feeNoChange, haveSats: totalSats },
);
}
change = totalSats - fixedOut - feeWithChange;
withChange = change >= P2PKH_DUST_SAT;
if (withChange) chainFee = feeWithChange;
else { chainFee = totalSats - fixedOut; change = 0n; } // the remainder is below dust: it goes to the miner
const transaction = build(withChange, change);
const outputMap = {};
let i = 0;
if (withChange) outputMap.change = i++;
outputMap.opReturn = i++;
outputMap.beacon = i++;
return {
name: rawName,
displayName: displayName(rawName),
category: certificateUtxo.token.category,
transaction,
sourceOutputs: inputs.map(toSourceOutput),
inputUtxos: inputs,
outputMap,
payload,
costs: {
beaconDustSats: BEACON_DUST_SAT,
chainFeeSats: chainFee,
changeSats: change,
totalInputSats: totalSats,
netCostSats: chainFee + BEACON_DUST_SAT,
},
};
}
throw new Error("could not converge on a fee — please retry");
}
/**
* Build a TUPD transaction — mirror of buildUpdateTx for TLD-registry NFTs.
*

View file

@ -11,11 +11,13 @@ import {
broadcast as broadcastTx,
addressToLockingBytecode,
toTokenAddress,
getTransaction,
} from "./chain.js";
import {
buildRegistrationTx,
buildTldRegistrationTx,
buildUpdateTx,
buildReleaseTx,
buildUpdateTldTx,
buildPrepTx,
hasGenesisInput,
@ -99,11 +101,19 @@ export async function checkAvailability(client, name) {
const key = displayName(raw);
const index = await buildIndex({ client });
const entry = index.get(key) ?? null;
// A released name is free only once its release is in a block: a REG seen
// while the release is unconfirmed could be mined beside it and be void
// (PROTOCOL-ADDENDUM-release.md, the one-block gap).
const releasedAt = index.released?.get(key);
const releasePending = releasedAt !== undefined && !(releasedAt > 0);
return {
name: key,
rawName: raw,
available: !entry,
available: !entry && !releasePending,
entry,
releasedAt: releasedAt ?? null,
releasePending,
reason: entry ? "registered" : releasePending ? "release-pending" : null,
recordBudgetBytes: payloadBudget(raw),
};
}
@ -229,7 +239,9 @@ export async function buildCosignRequest(client, {
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`);
if (!availability.available) throw new Error(availability.releasePending
? `"${availability.name}" was just released — it can be registered once that release is in a block`
: `"${availability.name}" is already registered`);
onProgress("loading-coins");
let utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
@ -314,7 +326,9 @@ export async function registerWithBuiltInWallet(client, {
}) {
onProgress("checking-availability");
const availability = await checkAvailability(client, name);
if (!availability.available) throw new Error(`"${availability.name}" is already registered`);
if (!availability.available) throw new Error(availability.releasePending
? `"${availability.name}" was just released — it can be registered once that release is in a block`
: `"${availability.name}" is already registered`);
onProgress("loading-coins");
let utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
@ -717,7 +731,9 @@ export async function registerWithExternalWallet(client, {
}) {
onProgress("checking-availability");
const availability = await checkAvailability(client, name);
if (!availability.available) throw new Error(`"${availability.name}" is already registered`);
if (!availability.available) throw new Error(availability.releasePending
? `"${availability.name}" was just released — it can be registered once that release is in a block`
: `"${availability.name}" is already registered`);
onProgress("reading-wallet");
const addresses = await session.getAddresses();
@ -841,6 +857,97 @@ export async function setRecordsWithBuiltInWallet(client, {
return { name: built.name, displayName: built.displayName, category: built.category, txid, records, costs: built.costs };
}
/**
* Was this UTXO created by a beacon transaction? Resolvers prove a release
* against the beacon history, so only such a certificate can be released
* directly; one that arrived by a plain transfer is anchored with an UPD first.
*/
export async function isAnchored(client, utxo, beaconAddress = REGISTRAR.beacon) {
const tx = await getTransaction(client, utxo.txid, true);
const beaconHex = binToHex(addressToLockingBytecode(beaconAddress));
return (tx?.vout ?? []).some((v) => v.scriptPubKey?.hex === beaconHex);
}
/**
* Release a name: burn its certificate with a REL so the name returns to the
* pool (PROTOCOL-ADDENDUM-release.md). Irreversible. The name can be
* registered again, by anyone, from the block after the release confirms.
*
* If the certificate is not anchored in a beacon transaction, an UPD with the
* current records is published first and the release spends its output.
*/
export async function releaseWithBuiltInWallet(client, {
wallet,
name,
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 holdsCert = (u) => u.token?.category === entry.category && u.token?.nft;
let utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
let certificateUtxo = utxos.find(holdsCert);
if (!certificateUtxo) {
throw new Error(
`this wallet does not hold the certificate for "${displayName(raw)}" — only the owner can release it`,
);
}
let anchorTxid = null;
if (!(await isAnchored(client, certificateUtxo))) {
onProgress("anchoring");
const upd = buildUpdateTx({
name,
ownerAddress: wallet.address,
ownerTokenAddress: toTokenAddress(wallet.address),
records: entry.records ?? {},
certificateUtxo,
utxos: utxos.filter((u) => !u.token),
beaconAddress: REGISTRAR.beacon,
feeRate,
addressToLockingBytecode,
});
const signedUpd = signTransaction(upd.transaction, upd.sourceOutputs, (h) => wallet.keyFor(h));
anchorTxid = await broadcastTx(client, signedUpd.hex);
onProgress("anchored", { txid: anchorTxid });
// Wait for the server to show the certificate at its new outpoint.
certificateUtxo = null;
for (let i = 0; i < 12 && !certificateUtxo; i++) {
await new Promise((r) => setTimeout(r, 1500));
utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
certificateUtxo = utxos.find((u) => holdsCert(u) && u.txid === anchorTxid) ?? null;
}
if (!certificateUtxo) {
throw new Error("the anchoring update was broadcast but is not visible yet — try the release again in a minute");
}
}
onProgress("building");
const built = buildReleaseTx({
name,
ownerAddress: wallet.address,
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("released", { txid });
return { name: built.name, displayName: built.displayName, category: built.category, txid, anchorTxid, 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

View file

@ -17,6 +17,8 @@ export {
splitServiceFee,
setRecordsWithBuiltInWallet,
setRecordsWithExternalWallet,
releaseWithBuiltInWallet,
isAnchored,
setTldRecordsWithBuiltInWallet,
signRecordsManifest,
findCertificateUtxo,
@ -27,6 +29,7 @@ export {
buildRegistrationTx,
buildTldRegistrationTx,
buildUpdateTx,
buildReleaseTx,
buildPrepTx,
hasGenesisInput,
toWcTransactionObject,

View file

@ -179,3 +179,57 @@ test("names that were never released are untouched by the new rule", async () =>
assert.deepEqual(idx.get("plain.x").records, { u: "https://two.example" });
assert.equal(idx.released.size, 0);
});
// ---------- the transaction ----------
import { buildReleaseTx, InsufficientFundsError } from "../src/lib/register-tx.js";
import { addressToLockingBytecode } from "../src/lib/chain.js";
import { NETWORK } from "../src/lib/resolver-web.js";
import { encodeTransaction, binToHex } from "@bitauth/libauth";
const OWNER = NETWORK.operatorAddress;
const certUtxo = (name, sats = 1000n) => ({
txid: txid(7), vout: 1, satoshis: sats, address: OWNER, lockingBytecode: addressToLockingBytecode(OWNER),
token: { category: A, amount: 0n, nft: { capability: "none", commitment: hex(name) } },
});
const coin = (id, sats) => ({ txid: txid(id), vout: 0, satoshis: sats, address: OWNER, lockingBytecode: addressToLockingBytecode(OWNER) });
test("buildReleaseTx burns the certificate: no output carries a token", () => {
const built = buildReleaseTx({ name: "gone.x", ownerAddress: OWNER, certificateUtxo: certUtxo("gone.x"), addressToLockingBytecode });
const t = built.transaction;
assert.equal(binToHex(t.inputs[0].outpointTransactionHash), txid(7), "the certificate is input 0");
assert.equal(t.inputs.length, 1, "the certificate's own dust pays for the release");
assert.ok(t.outputs.every((o) => !o.token), "an output carrying the category would cancel the burn");
assert.equal(built.sourceOutputs[0].token.nft.commitment.length, "gone.x".length, "signing still sees the token on the input");
const beaconOut = t.outputs[built.outputMap.beacon];
assert.equal(binToHex(beaconOut.lockingBytecode), binToHex(addressToLockingBytecode(NETWORK.beacon)));
assert.equal(beaconOut.valueSatoshis, 600n);
const script = Buffer.from(t.outputs[built.outputMap.opReturn].lockingBytecode);
const text = script.subarray(script[1] === 0x4c ? 3 : 2).toString("utf8");
assert.ok(text.startsWith("BNS1"));
assert.deepEqual(JSON.parse(text.slice(4)), { o: "REL", n: "gone.x", r: {} });
const out = t.outputs.reduce((a, o) => a + o.valueSatoshis, 0n);
assert.equal(1000n - out, built.costs.chainFeeSats, "everything not sent is fee");
// an unsigned P2PKH input grows by ~107 bytes when signed; 1 sat/byte must still hold
assert.ok(built.costs.chainFeeSats >= BigInt(encodeTransaction(t).length), "fee covers at least 1 sat/byte");
});
test("buildReleaseTx tops up from plain coins only when the certificate cannot pay", () => {
assert.throws(
() => buildReleaseTx({ name: "poor.x", ownerAddress: OWNER, certificateUtxo: certUtxo("poor.x", 700n), utxos: [], addressToLockingBytecode }),
InsufficientFundsError,
);
const built = buildReleaseTx({ name: "poor.x", ownerAddress: OWNER, certificateUtxo: certUtxo("poor.x", 700n), utxos: [coin(8, 50_000n)], addressToLockingBytecode });
assert.equal(built.transaction.inputs.length, 2);
assert.ok(built.outputMap.change !== undefined, "the top-up's remainder comes back as change");
assert.ok(built.transaction.outputs.every((o) => !o.token));
const out = built.transaction.outputs.reduce((a, o) => a + o.valueSatoshis, 0n);
assert.equal(50_700n - out, built.costs.chainFeeSats);
});
test("buildReleaseTx refuses a certificate for a different name", () => {
assert.throws(() => buildReleaseTx({ name: "other.x", ownerAddress: OWNER, certificateUtxo: certUtxo("gone.x"), addressToLockingBytecode }), /commitment mismatch/);
});