BNS: a transaction with more than one protocol message carries none

Resolvers took the last message in a transaction. Wherever one party fixes
a message and another assembles the transaction - the delivery escrow pins
the registration at output 1 - a later output could replace it and every
resolver would read that one. Counting instead of choosing removes the
question: one message, or the transaction says nothing.

Same rule in all three resolvers. The Android parser now recognises the
same set of operations as the JavaScript one, since what counts as a
message decides which transactions are void. No transaction on chipnet
carried more than one.

Three older suites built registrations that never created their
certificate's category; their fixtures now do, as the sole-certificate
rule requires.
This commit is contained in:
Local Dev 2026-10-04 16:53:58 +02:00
parent e0eca1190e
commit b4fc73f0fa
8 changed files with 107 additions and 35 deletions

View file

@ -62,6 +62,19 @@ export function parseOpReturn(scriptHex) {
return null;
}
// One message per transaction: more than one parseable BNS payload means none.
// KEEP IN LOCKSTEP with resolver-web.js onlyPayload (the reasoning is there).
export function onlyPayload(vouts) {
let found = null;
for (const v of vouts || []) {
const p = parseOpReturn(v?.scriptPubKey?.hex);
if (!p) continue;
if (found) return null;
found = p;
}
return found;
}
// The set of payload opcodes this codec recognises. New opcodes go here AND in
// resolver-web.js — divergence is a consensus split.
export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]);
@ -126,8 +139,7 @@ async function buildTldMap(provider) {
const tlds = new Map();
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
const tx = txs.get(tx_hash);
let payload = null;
for (const vout of tx.vout) payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
const payload = onlyPayload(tx.vout);
if (!payload) continue;
if (typeof payload.n !== "string" || !/^[a-z0-9-]{1,16}$/.test(payload.n)) continue;
const tld = payload.n;
@ -248,10 +260,7 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
const tx = txs.get(tx_hash);
let payload = null;
for (const vout of tx.vout) {
payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
}
const payload = onlyPayload(tx.vout);
if (!payload) continue;
const name = payload.n;

View file

@ -58,6 +58,8 @@ export interface BnsNetwork {
bchnRpcPort: number;
blocklists: { name: string; beacon: string; scripthash?: string }[];
}
/** The transaction's single BNS payload; null when it has none or more than one. */
export function onlyPayload(vouts: any[]): any | null;
/** REG/TREG: is `mint` the only token of its category there is or can ever be? */
export function soleCertificate(tx: any, mint: any): boolean;
/** REL: does `tx` burn `entry`'s certificate, spending it from a beacon transaction in `txs`? */

View file

@ -328,6 +328,28 @@ function parseOpReturnHex(hex) {
return null;
}
// ONE MESSAGE PER TRANSACTION. Every reducer asks this for a transaction's
// payload. If more than one output parses as a BNS message, the transaction
// carries none.
//
// The earlier rule was "the last one wins", which let a later output silently
// replace an earlier one. That matters wherever one party fixes a message and
// another builds the rest of the transaction — the delivery escrow pins the
// registration at output 1; a deliverer could add a second message after it
// and every resolver would read that one instead. Making such a transaction
// void removes the question of which message counts. KEEP IN LOCKSTEP with
// bns.js onlyPayload and the mobile Bns.java.
export function onlyPayload(vouts) {
let found = null;
for (const v of vouts || []) {
const p = parseOpReturnHex(v && v.scriptPubKey ? v.scriptPubKey.hex : null);
if (!p) continue;
if (found) return null;
found = p;
}
return found;
}
// The set of payload opcodes this codec recognises. New opcodes go here AND in
// bns.js — divergence is a consensus split.
export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]);
@ -446,8 +468,7 @@ export async function fetchTldMap(el, network = NETWORK) {
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
const tx = txs.get(tx_hash);
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
const payload = onlyPayload(vouts);
if (!payload) continue;
if (typeof payload.n !== "string" || !TLD_RE.test(payload.n)) continue;
const tld = payload.n;
@ -614,8 +635,7 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw
const tx = txs.get(tx_hash);
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
const payload = onlyPayload(vouts);
if (!payload) continue;
const rawName = payload.n;
if (!RAWNAME_RE.test(rawName)) continue;
@ -708,8 +728,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txMap.has(h.tx_hash)), txMap)) {
const tx = txMap.get(tx_hash);
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
const payload = onlyPayload(vouts);
if (!payload) continue;
const rawName = payload.n;
if (!RAWNAME_RE.test(rawName)) continue;
@ -881,11 +900,7 @@ export function reduceBlocklist({ history, txs, address, source = null }) {
const txMap = txs instanceof Map ? txs : new Map(Object.entries(txs || {}));
const ordered = orderByDependency([...(history || [])].filter((h) => txMap.has(h.tx_hash)).sort(byHeightThenTxid), txMap);
const payloadOf = (tx) => {
let payload = null;
for (const v of tx.vout || []) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
return payload;
};
const payloadOf = (tx) => onlyPayload(tx.vout);
// Pass 1: the reports. Looked up by txid, so their position does not matter.
const reports = new Map();

View file

@ -17,7 +17,7 @@
import { createHash } from "node:crypto";
import { parseOpReturn } from "./bns.js";
import { soleCertificate } from "./resolver-web.js";
import { soleCertificate, onlyPayload } from "./resolver-web.js";
const TLD_RE = /^[a-z0-9-]{1,16}$/;
@ -101,8 +101,7 @@ export function buildTldSnapshot({
const tx = txMap.get(tx_hash);
if (!tx) continue;
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturn(v.scriptPubKey?.hex) ?? payload;
const payload = onlyPayload(vouts); // one message per transaction (resolver-web.js)
if (!payload) continue;
if (typeof payload.n !== "string" || !TLD_RE.test(payload.n)) continue;
const tld = payload.n;

View file

@ -9,6 +9,7 @@ import assert from "node:assert/strict";
import { NameIndex, buildFromCache } from "../src/lib/name-index.js";
import { verifyPortfolio, renderPortfolio, heldNames, PORTFOLIO_STATUS } from "../src/lib/portfolio.js";
import { addressToLockingBytecode } from "../src/lib/chain.js";
import { binToHex, sha256 } from "@bitauth/libauth";
const OPERATOR = "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu";
const OTHER = "bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs";
@ -21,7 +22,7 @@ function regTx(txid, name, category) {
const len = (hex.length / 2).toString(16).padStart(2, "0");
return {
txid,
vin: [],
vin: [{ txid: category, vout: 0 }], // the certificate's category is created here (sole-certificate rule)
vout: [
{ n: 0, scriptPubKey: { hex: `6a4c${len}${hex}` } },
{ n: 1, scriptPubKey: { hex: "76a914" + "11".repeat(20) + "88ac" },
@ -59,12 +60,8 @@ function fakeClient({ history = [], txs = {}, utxos = {} } = {}) {
}
// scripthash helper mirroring chain.js
let _sha;
const _sha = { binToHex, sha256 };
function shOf(address) {
if (!_sha) {
// lazily to keep the import list short
_sha = require("@bitauth/libauth");
}
const bytes = addressToLockingBytecode(address);
return _sha.binToHex(_sha.sha256.hash(bytes).slice().reverse());
}

View file

@ -111,3 +111,53 @@ test("TLD certificates obey the same rule, in every reducer that reads them", as
const snap = buildTldSnapshot({ history, txs, beacon: "x" });
assert.deepEqual((snap.tlds ?? []).map((t) => t.tld), ["zz"]);
});
// ---------- one message per transaction ----------
import { onlyPayload } from "../src/lib/resolver-web.js";
test("a transaction with two protocol messages carries none, in every reducer", async () => {
const honest = reg(1, "single.x");
assert.deepEqual(onlyPayload(honest.vout), { o: "REG", n: "single.x", r: { u: "https://honest.example" } });
assert.deepEqual(legacy.onlyPayload(honest.vout), onlyPayload(honest.vout));
// The escrow attack at the resolver: the agreed registration at output 1,
// and a second message after it that used to win.
const second = [
{ o: "REG", n: "single.x", r: { u: "https://deliverer.example" } },
{ o: "UPD", n: "single.x", r: {} },
{ o: "REG", n: "elsewhere.x", r: {} },
];
for (const payload of second) {
const tx = { ...honest, vout: [...honest.vout, data(2, payload)] };
assert.equal(onlyPayload(tx.vout), null);
assert.equal(legacy.onlyPayload(tx.vout), null);
const idx = await names([[tx, 100]]);
assert.equal(idx.size, 0, `nothing registers from a transaction that says two things (${payload.o} ${payload.n})`);
}
// Other data outputs are nobody's business: only BNS messages are counted.
const withMemo = { ...honest, vout: [...honest.vout, { n: 2, value: 0, scriptPubKey: { hex: "6a0568656c6c6f" } }] };
assert.ok((await names([[withMemo, 100]])).has("single.x"));
// TLD certificates and the blocklist read through the same function.
const t = reg(2, "zz", { op: "TREG" });
const doubled = { ...t, vout: [...t.vout, data(2, { o: "TREG", n: "yy", r: {} })] };
const history = [{ tx_hash: doubled.txid, height: 5 }];
const txs = new Map([[doubled.txid, doubled]]);
const el = { call: async (m, [a]) => (m === "blockchain.scripthash.get_history" ? history.map((h) => ({ ...h })) : txs.get(a)) };
assert.equal((await fetchTldMap(el)).size, 0);
assert.deepEqual((buildTldSnapshot({ history, txs, beacon: "x" }).tlds ?? []).length, 0);
});
test("what counts as a message is the same everywhere", () => {
const honest = reg(1, "single.x");
const plus = (...payloads) => [...honest.vout, ...payloads.map((p, i) => data(2 + i, p))];
for (const read of [onlyPayload, legacy.onlyPayload]) {
// list entries are messages too, though no name reducer acts on them
assert.equal(read(plus({ o: "BLK", n: "single.x", c: "scam" })), null);
assert.equal(read(plus({ o: "RPT", n: ["a.x", "b.x"] })), null);
// malformed or unknown ones are not messages, and so do not void the transaction
assert.equal(read(plus({ o: "UPD", n: 5 }, { o: "XYZ", n: "single.x" }, { o: "BLK", n: ["a.x"] }))?.o, "REG");
}
});

View file

@ -65,11 +65,11 @@ function mockProvider({ nameHistory, tldHistory, txs }) {
const CAT_MEMPOOL = "ff".repeat(32);
const txs = new Map();
txs.set("tX", { txid: "tX", vin: [], vout: [beaconVout(), opreturnVout("TREG", "x", { policy: "open" }), mintVout(CAT_X, "x")] });
txs.set("tEarly", { txid: "tEarly", vin: [], vout: [beaconVout(), opreturnVout("REG", "early.x", {}), mintVout(CAT_EARLY, "early.x")] });
txs.set("tLate", { txid: "tLate", vin: [], vout: [beaconVout(), opreturnVout("REG", "late.x", {}), mintVout(CAT_LATE, "late.x")] });
txs.set("tOrphan", { txid: "tOrphan", vin: [], vout: [beaconVout(), opreturnVout("REG", "orphan.zz",{}), mintVout(CAT_ORPHAN, "orphan.zz")] });
txs.set("tMempool", { txid: "tMempool", vin: [], vout: [beaconVout(), opreturnVout("REG", "mempool.x",{}), mintVout(CAT_MEMPOOL, "mempool.x")] });
txs.set("tX", { txid: "tX", vin: [{ txid: CAT_X, vout: 0 }], vout: [beaconVout(), opreturnVout("TREG", "x", { policy: "open" }), mintVout(CAT_X, "x")] });
txs.set("tEarly", { txid: "tEarly", vin: [{ txid: CAT_EARLY, vout: 0 }], vout: [beaconVout(), opreturnVout("REG", "early.x", {}), mintVout(CAT_EARLY, "early.x")] });
txs.set("tLate", { txid: "tLate", vin: [{ txid: CAT_LATE, vout: 0 }], vout: [beaconVout(), opreturnVout("REG", "late.x", {}), mintVout(CAT_LATE, "late.x")] });
txs.set("tOrphan", { txid: "tOrphan", vin: [{ txid: CAT_ORPHAN, vout: 0 }], vout: [beaconVout(), opreturnVout("REG", "orphan.zz",{}), mintVout(CAT_ORPHAN, "orphan.zz")] });
txs.set("tMempool", { txid: "tMempool", vin: [{ txid: CAT_MEMPOOL, vout: 0 }], vout: [beaconVout(), opreturnVout("REG", "mempool.x",{}), mintVout(CAT_MEMPOOL, "mempool.x")] });
const provider = mockProvider({
nameHistory: [
@ -102,8 +102,8 @@ function mockProvider({ nameHistory, tldHistory, txs }) {
const CAT_Y = "11".repeat(32);
const CAT_N = "22".repeat(32);
const txs = new Map();
txs.set("tYmempool", { txid: "tYmempool", vin: [], vout: [beaconVout(), opreturnVout("TREG", "y", {}), mintVout(CAT_Y, "y")] });
txs.set("tNameUnderY", { txid: "tNameUnderY", vin: [], vout: [beaconVout(), opreturnVout("REG", "name.y", {}), mintVout(CAT_N, "name.y")] });
txs.set("tYmempool", { txid: "tYmempool", vin: [{ txid: CAT_Y, vout: 0 }], vout: [beaconVout(), opreturnVout("TREG", "y", {}), mintVout(CAT_Y, "y")] });
txs.set("tNameUnderY", { txid: "tNameUnderY", vin: [{ txid: CAT_N, vout: 0 }], vout: [beaconVout(), opreturnVout("REG", "name.y", {}), mintVout(CAT_N, "name.y")] });
const provider = mockProvider({
nameHistory: [{ tx_hash: "tNameUnderY", height: 0 }],

View file

@ -172,8 +172,8 @@ await (async () => {
const CAT_TWO = "bb".repeat(32);
// Each tx pays the beacon (proof it belongs there), carries the TREG/TUPD
// OP_RETURN, and mints/transfers the TLD NFT (proof of ownership).
const t1 = { txid: "t1", vin: [], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "x", { policy: "open", fee_bps: 500 }), nftMintOutput(CAT_X, "x")] };
const t2 = { txid: "t2", vin: [], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "two", { policy: "open" }), nftMintOutput(CAT_TWO, "two")] };
const t1 = { txid: "t1", vin: [{ txid: CAT_X, vout: 0 }], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "x", { policy: "open", fee_bps: 500 }), nftMintOutput(CAT_X, "x")] };
const t2 = { txid: "t2", vin: [{ txid: CAT_TWO, vout: 0 }], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "two", { policy: "open" }), nftMintOutput(CAT_TWO, "two")] };
const t3 = { txid: "t3", vin: [{ txid: "t1", vout: 2 }], vout: [beaconOutput(rawTld), opReturnOutput("TUPD", "x", { policy: "covenant", fee_bps: 1000 }), nftMintOutput(CAT_X, "x", "bchtest:qnew-owner")] };
idx.processTx(t1, 100);
idx.processTx(t2, 100);