diff --git a/src/lib/bns.js b/src/lib/bns.js index 21b7a8f..135e1a4 100644 --- a/src/lib/bns.js +++ b/src/lib/bns.js @@ -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; diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts index 4296a08..a691567 100644 --- a/src/lib/resolver-web.d.ts +++ b/src/lib/resolver-web.d.ts @@ -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`? */ diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index e1e201f..c6240e8 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -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(); diff --git a/src/lib/tld-index.js b/src/lib/tld-index.js index 5b9874b..9011533 100644 --- a/src/lib/tld-index.js +++ b/src/lib/tld-index.js @@ -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; diff --git a/test/portfolio-index.test.mjs b/test/portfolio-index.test.mjs index bef5fbb..d1b3b9f 100644 --- a/test/portfolio-index.test.mjs +++ b/test/portfolio-index.test.mjs @@ -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()); } diff --git a/test/sole-certificate.test.mjs b/test/sole-certificate.test.mjs index 69c6476..cff8c20 100644 --- a/test/sole-certificate.test.mjs +++ b/test/sole-certificate.test.mjs @@ -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"); + } +}); diff --git a/test/tld-enforcement.test.mjs b/test/tld-enforcement.test.mjs index 572391d..300fef9 100644 --- a/test/tld-enforcement.test.mjs +++ b/test/tld-enforcement.test.mjs @@ -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 }], diff --git a/test/tld-index.test.mjs b/test/tld-index.test.mjs index d47dff7..f296c4a 100644 --- a/test/tld-index.test.mjs +++ b/test/tld-index.test.mjs @@ -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);