diff --git a/src/lib/register-tx.js b/src/lib/register-tx.js index 1838ece..7261130 100644 --- a/src/lib/register-tx.js +++ b/src/lib/register-tx.js @@ -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. * diff --git a/src/lib/registrar.js b/src/lib/registrar.js index 1e6cb2d..bf6a254 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -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 diff --git a/src/web/register-entry.js b/src/web/register-entry.js index 0aa8d51..a17c1c0 100644 --- a/src/web/register-entry.js +++ b/src/web/register-entry.js @@ -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, diff --git a/test/release.test.mjs b/test/release.test.mjs index e169770..f0657bd 100644 --- a/test/release.test.mjs +++ b/test/release.test.mjs @@ -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/); +});