// Offline validation of the certificate-minting tx builder. // No network, no funds — synthetic UTXOs only. Run: node --test test/ import test from "node:test"; import assert from "node:assert/strict"; import { buildRegistrationTx, buildUpdateTx, buildPrepTx, normalizeName, encodePayload, opReturnScript, hasGenesisInput, NoGenesisInputError, InsufficientFundsError, BEACON, CERTIFICATE_DUST_SAT, BEACON_DUST_SAT, } from "../src/lib/register-tx.js"; import { signTransaction, txidOf } from "../src/lib/sign-tx.js"; import { BuiltInWallet } from "../src/lib/wallet-web.js"; import { addressToLockingBytecode, toTokenAddress } from "../src/lib/chain.js"; import { parseOpReturn } from "../src/lib/bns.js"; import { encodeTransaction, decodeTransaction, binToHex, hexToBin, binToUtf8, } from "@bitauth/libauth"; // A fixed phrase so the test is deterministic. TEST ONLY — never funded. const TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; const FEE_ADDRESS = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"; const utxo = (txid, vout, sats, wallet, token) => ({ txid, vout, satoshis: BigInt(sats), address: wallet.address, lockingBytecode: addressToLockingBytecode(wallet.address), ...(token ? { token } : {}), }); const A = "1111111111111111111111111111111111111111111111111111111111111111"; const B = "2222222222222222222222222222222222222222222222222222222222222222"; const C = "3333333333333333333333333333333333333333333333333333333333333333"; async function fixture() { const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC); return { wallet, base: { ownerAddress: wallet.address, ownerTokenAddress: wallet.tokenAddress, feeAddress: FEE_ADDRESS, addressToLockingBytecode, }, }; } test("normalizeName keeps .bch bare and other TLDs qualified", () => { assert.equal(normalizeName("Alice"), "alice"); assert.equal(normalizeName("alice.bch"), "alice"); assert.equal(normalizeName("alice.p2p"), "alice.p2p"); assert.equal(normalizeName("www.alice.p2p"), "alice.p2p"); assert.throws(() => normalizeName("bad_name.bch")); assert.throws(() => normalizeName("this-label-is-far-too-long-to-be-a-valid-bns-name.bch")); }); test("payload round-trips through the on-chain parser", () => { const script = opReturnScript(encodePayload("REG", "alice.p2p", { u: "https://x.example" })); const parsed = parseOpReturn(binToHex(script)); assert.deepEqual(parsed, { o: "REG", n: "alice.p2p", r: { u: "https://x.example" } }); }); test("payload over 200 bytes is rejected", () => { assert.throws(() => encodePayload("REG", "alice", { h: "x".repeat(300) }), /records too large/); }); test("registration mints the certificate to the buyer, not to us", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice.p2p", records: { u: "https://example.org" }, feeSats: 5000n, utxos: [utxo(A, 0, 100_000, wallet)], }); const cert = built.transaction.outputs[built.outputMap.certificate]; assert.ok(cert.token, "certificate output carries token data"); assert.equal( binToHex(cert.lockingBytecode), binToHex(addressToLockingBytecode(wallet.tokenAddress)), "certificate is locked to the buyer's own key", ); assert.equal(binToUtf8(cert.token.nft.commitment), "alice.p2p"); assert.equal(cert.token.nft.capability, "none"); assert.equal(cert.valueSatoshis, CERTIFICATE_DUST_SAT); }); test("category ID equals the genesis input's txid (CashTokens consensus rule)", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice", utxos: [utxo(B, 3, 50_000, wallet), utxo(A, 0, 50_000, wallet)], }); assert.equal(built.inputUtxos[0].vout, 0, "input 0 is the genesis input"); assert.equal(built.inputUtxos[0].txid, A); assert.equal(built.category, A, "category ID is the genesis outpoint's txid"); const cert = built.transaction.outputs[built.outputMap.certificate]; assert.equal(binToHex(cert.token.category), A); }); test("no vout-0 coin -> NoGenesisInputError (and prep tx fixes it)", async () => { const { wallet, base } = await fixture(); const utxos = [utxo(A, 1, 100_000, wallet), utxo(B, 2, 100_000, wallet)]; assert.equal(hasGenesisInput(utxos), false); assert.throws( () => buildRegistrationTx({ ...base, name: "alice", utxos }), NoGenesisInputError, ); const prep = buildPrepTx({ ownerAddress: wallet.address, utxos, addressToLockingBytecode }); assert.equal( binToHex(prep.transaction.outputs[0].lockingBytecode), binToHex(addressToLockingBytecode(wallet.address)), "prep tx output 0 pays the owner, making a genesis-eligible coin", ); }); test("change lands at vout 0 so the next registration is self-sufficient", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice", feeSats: 1000n, utxos: [utxo(A, 0, 100_000, wallet)], }); assert.equal(built.outputMap.change, 0); assert.ok(built.costs.changeSats > 0n); assert.equal( binToHex(built.transaction.outputs[0].lockingBytecode), binToHex(addressToLockingBytecode(wallet.address)), ); }); test("beacon, service fee and OP_RETURN are all present and correct", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice.p2p", records: { ip: "203.0.113.9" }, feeSats: 25_000n, utxos: [utxo(A, 0, 200_000, wallet)], }); const o = built.transaction.outputs; assert.equal(o[built.outputMap.beacon].valueSatoshis, BEACON_DUST_SAT); assert.equal( binToHex(o[built.outputMap.beacon].lockingBytecode), binToHex(addressToLockingBytecode(BEACON)), ); assert.equal(o[built.outputMap.serviceFee].valueSatoshis, 25_000n); assert.equal( binToHex(o[built.outputMap.serviceFee].lockingBytecode), binToHex(addressToLockingBytecode(FEE_ADDRESS)), ); assert.deepEqual(parseOpReturn(binToHex(o[built.outputMap.opReturn].lockingBytecode)), { o: "REG", n: "alice.p2p", r: { ip: "203.0.113.9" }, }); assert.equal(o[built.outputMap.opReturn].valueSatoshis, 0n); }); test("the books balance: inputs = outputs + chain fee", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice", feeSats: 7777n, utxos: [utxo(A, 0, 100_000, wallet)], }); const outSum = built.transaction.outputs.reduce((s, o) => s + o.valueSatoshis, 0n); const inSum = built.inputUtxos.reduce((s, u) => s + u.satoshis, 0n); assert.equal(inSum, outSum + built.costs.chainFeeSats); assert.equal(inSum, 100_000n); }); test("the fee actually covers the transaction at 1 sat/byte", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice", feeSats: 1000n, utxos: [utxo(A, 0, 60_000, wallet), utxo(B, 1, 60_000, wallet)], }); const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h)); const size = hexToBin(signed.hex).length; assert.ok( built.costs.chainFeeSats >= BigInt(size), `fee ${built.costs.chainFeeSats} must cover ${size} bytes`, ); }); test("certificate UTXOs are never spent as change", async () => { const { wallet, base } = await fixture(); const certToken = { category: C, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("held.p2p")) }, }; const built = buildRegistrationTx({ ...base, name: "alice", utxos: [ utxo(A, 0, 100_000, wallet), utxo(C, 0, 1000, wallet, certToken), // an already-owned name — must not be touched ], }); assert.ok( built.inputUtxos.every((u) => !u.token), "no token-bearing UTXO may be selected as a funding coin", ); }); test("insufficient funds is reported, not silently under-paid", async () => { const { wallet, base } = await fixture(); assert.throws( () => buildRegistrationTx({ ...base, name: "alice", feeSats: 50_000n, utxos: [utxo(A, 0, 2000, wallet)] }), InsufficientFundsError, ); }); test("signing produces a well-formed tx that survives an encode/decode round trip", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice.p2p", records: { u: "https://example.org" }, feeSats: 5000n, utxos: [utxo(A, 0, 100_000, wallet)], }); const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h)); const redecoded = decodeTransaction(hexToBin(signed.hex)); assert.notEqual(typeof redecoded, "string", `decode failed: ${redecoded}`); assert.equal(binToHex(encodeTransaction(redecoded)), signed.hex, "encode/decode is lossless"); // Byte order: what we put in the outpoint is what comes back out. assert.equal(binToHex(redecoded.inputs[0].outpointTransactionHash), A); const cert = redecoded.outputs.find((o) => o.token?.nft); assert.equal(binToHex(cert.token.category), A); assert.equal(binToUtf8(cert.token.nft.commitment), "alice.p2p"); // Every input is really signed (placeholder is all-zero pushes). for (const i of redecoded.inputs) { assert.equal(i.unlockingBytecode.length, 100); assert.ok(i.unlockingBytecode.some((b) => b !== 0), "input carries a real signature"); } assert.match(signed.txid, /^[0-9a-f]{64}$/); assert.equal(signed.txid, txidOf(hexToBin(signed.hex))); }); test("the resolver's own rules accept the transaction we build", async () => { const { wallet, base } = await fixture(); const built = buildRegistrationTx({ ...base, name: "alice.p2p", records: { u: "https://example.org" }, utxos: [utxo(A, 0, 100_000, wallet)], }); const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h)); const tx = decodeTransaction(hexToBin(signed.hex)); // Mirror of buildIndex()'s REG validation. let payload = null; for (const o of tx.outputs) payload = parseOpReturn(binToHex(o.lockingBytecode)) ?? payload; assert.ok(payload, "a BNS1 payload is discoverable"); assert.equal(payload.o, "REG"); const commitment = binToHex(new TextEncoder().encode(payload.n)); const mint = tx.outputs.find((o) => o.token?.nft && binToHex(o.token.nft.commitment) === commitment); assert.ok(mint, "the same tx mints a certificate whose commitment is the name"); const paysBeacon = tx.outputs.some( (o) => binToHex(o.lockingBytecode) === binToHex(addressToLockingBytecode(BEACON)) && o.valueSatoshis === BEACON_DUST_SAT, ); assert.ok(paysBeacon, "the beacon is paid so the registration is discoverable"); }); test("update tx moves the certificate back to the owner as ownership proof", async () => { const { wallet, base } = await fixture(); const certUtxo = utxo(C, 1, 1000, wallet, { category: C, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("alice.p2p")) }, }); const built = buildUpdateTx({ ...base, name: "alice.p2p", records: { ip: "198.51.100.7" }, certificateUtxo: certUtxo, utxos: [utxo(A, 0, 50_000, wallet)], }); assert.equal(built.inputUtxos[0], certUtxo, "the certificate is spent — that is the proof"); const cert = built.transaction.outputs[built.outputMap.certificate]; assert.equal(binToHex(cert.token.category), C, "same category survives the update"); assert.deepEqual( parseOpReturn(binToHex(built.transaction.outputs[built.outputMap.opReturn].lockingBytecode)), { o: "UPD", n: "alice.p2p", r: { ip: "198.51.100.7" } }, ); const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h)); assert.notEqual(typeof decodeTransaction(hexToBin(signed.hex)), "string"); }); test("updating the wrong name is refused", async () => { const { wallet, base } = await fixture(); const certUtxo = utxo(C, 1, 1000, wallet, { category: C, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("bob.p2p")) }, }); assert.throws( () => buildUpdateTx({ ...base, name: "alice.p2p", certificateUtxo: certUtxo, utxos: [utxo(A, 0, 50_000, wallet)], }), /commitment mismatch/, ); }); test("wallet: encrypt/decrypt round trip, wrong password rejected", async () => { const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC); const blob = await wallet.encrypt("correct horse battery staple"); assert.ok(!blob.includes("abandon"), "the seed phrase is not stored in the clear"); const back = await BuiltInWallet.decrypt(blob, "correct horse battery staple"); assert.equal(back.mnemonic, TEST_MNEMONIC); assert.equal(back.address, wallet.address); await assert.rejects(() => BuiltInWallet.decrypt(blob, "wrong"), /wrong password/); }); test("wallet: token address and plain address share one key", async () => { const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC); assert.match(wallet.address, /^bchtest:q/); assert.match(wallet.tokenAddress, /^bchtest:z/); assert.equal( binToHex(addressToLockingBytecode(wallet.address)), binToHex(addressToLockingBytecode(wallet.tokenAddress)), ); assert.equal(toTokenAddress(wallet.address), wallet.tokenAddress); assert.ok(wallet.keyFor(binToHex(addressToLockingBytecode(wallet.address)))); });