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:
parent
067771cf7f
commit
fcca514c5a
4 changed files with 275 additions and 4 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue