// Pay-by-QR order flow: pricing, deposit addresses, BIP21, and the registrar // minting on a buyer's behalf. // // The load-bearing property under test is that the registrar never ends up // holding the name. Everything else is recoverable; that is not. import test from "node:test"; import assert from "node:assert/strict"; import { binToHex, deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic, encodeHdPublicKey } from "@bitauth/libauth"; import { bip21, quotePrice, normaliseBuyerAddress, createOrder, depositAddressAt, depositXpub, ORDER_STATUS, DEPOSIT_PATH, } from "../src/lib/orders.js"; import { buildRegistrationTx } from "../src/lib/register-tx.js"; import { addressToLockingBytecode, toTokenAddress } from "../src/lib/chain.js"; import { BuiltInWallet } from "../src/lib/wallet-web.js"; import { signTransaction } from "../src/lib/sign-tx.js"; const REGISTRAR_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; const BUYER = "bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"; const A = "1111111111111111111111111111111111111111111111111111111111111111"; async function rootNode() { const seed = await deriveSeedFromBip39Mnemonic(REGISTRAR_MNEMONIC); return deriveHdPrivateNodeFromSeed(seed); } // ---------- pricing ---------- test("the price covers what the mint actually costs the registrar", () => { // 10k service fee + 500 chain + 1000 certificate dust + 600 beacon = 12100 -> 13000 const p = quotePrice({ serviceFeeSats: 10_000n }); assert.ok(p >= 12_100n, "must at least cover fee + chain + dust + beacon"); assert.equal(p % 1000n, 0n, "rounded to a tidy number for the QR"); assert.equal(p, 13_000n); }); test("a zero service fee still charges the chain costs, not nothing", () => { const p = quotePrice({ serviceFeeSats: 0n }); assert.ok(p >= 2100n, `free names must not cost the registrar money — got ${p}`); }); test("rounding never lands below the true cost", () => { for (const fee of [0n, 1n, 999n, 1000n, 10_000n, 123_456n]) { const raw = fee + 500n + 1000n + 600n; assert.ok(quotePrice({ serviceFeeSats: fee }) >= raw, `fee ${fee} underpriced`); } }); // ---------- BIP21 ---------- test("BIP21 URI is the shape wallets actually parse", () => { const uri = bip21({ address: BUYER, satoshis: 13_000n, label: "Silent Mode", message: "sirius.nav" }); assert.ok(uri.startsWith("bchtest:qqmcn373"), "keeps the cashaddr prefix"); assert.match(uri, /\?amount=0\.00013(&|$)/, "amount is BCH, with trailing zeros stripped"); assert.match(uri, /label=Silent%20Mode/); assert.match(uri, /message=sirius\.nav/); }); test("BIP21 amounts never carry trailing zeros (some wallets reject them)", () => { assert.match(bip21({ address: BUYER, satoshis: 100_000_000n }), /amount=1(&|$)/); assert.match(bip21({ address: BUYER, satoshis: 1000n }), /amount=0\.00001(&|$)/); assert.ok(!bip21({ address: BUYER, satoshis: 13_000n }).includes("0.00013000")); }); // ---------- the buyer's address: the field that loses names ---------- test("a plain q-address is silently upgraded to token-aware", () => { const r = normaliseBuyerAddress(BUYER); assert.equal(r.address, BUYER); assert.match(r.tokenAddress, /^bchtest:z/); assert.equal(r.wasTokenAware, false); assert.equal( binToHex(addressToLockingBytecode(r.address)), binToHex(addressToLockingBytecode(r.tokenAddress)), "same key either way — the prefix is only a wallet-safety flag", ); }); test("a token-aware address is accepted as-is and flagged", () => { const r = normaliseBuyerAddress(toTokenAddress(BUYER)); assert.equal(r.wasTokenAware, true); assert.equal(r.tokenAddress, toTokenAddress(BUYER)); }); test("a bare address without the prefix still works", () => { const r = normaliseBuyerAddress(BUYER.split(":")[1]); assert.equal(r.address, BUYER); }); test("a mainnet address on a chipnet registry is refused, not silently accepted", () => { assert.throws( () => normaliseBuyerAddress("bitcoincash:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"), /chipnet/, "sending a chipnet certificate to a mainnet address would lose it", ); }); test("typos and empty input are rejected with something a human can act on", () => { assert.throws(() => normaliseBuyerAddress(""), /enter the address/); assert.throws(() => normaliseBuyerAddress(" "), /enter the address/); assert.throws(() => normaliseBuyerAddress("bchtest:qqmcn373TYPOplhjrvs5"), /typo/); assert.throws(() => normaliseBuyerAddress("not an address at all"), /typo/); }); // ---------- deposit addresses ---------- test("every order gets its own deposit address", async () => { const node = await rootNode(); const seen = new Set(); for (let i = 0; i < 25; i++) { const a = depositAddressAt(i, { hdNode: node }); assert.match(a, /^bchtest:q/); assert.ok(!seen.has(a), `deposit address repeated at index ${i} — orders would be indistinguishable`); seen.add(a); } }); test("the public service can derive deposit addresses from an xpub alone", async () => { const node = await rootNode(); const xpub = encodeHdPublicKey({ network: "testnet", node: depositXpub(node) }); const xpubString = typeof xpub === "string" ? xpub : xpub.hdPublicKey; for (let i = 0; i < 5; i++) { assert.equal( depositAddressAt(i, { xpub: xpubString }), depositAddressAt(i, { hdNode: node }), `xpub and private derivation must agree at index ${i}`, ); } }); test("deposit addresses sit on their own branch, away from spending coins", () => { assert.match(DEPOSIT_PATH(0), /^m\/44'\/145'\/0'\/3\/0$/); assert.notEqual(DEPOSIT_PATH(0), "m/44'/145'/0'/0/0"); }); test("deriving without a key source is refused rather than guessed", () => { assert.throws(() => depositAddressAt(0, {}), /xpub or an hdNode/); }); // ---------- orders ---------- test("a new order starts unpaid and carries both address forms", async () => { const node = await rootNode(); const order = createOrder({ index: 7, name: "sirius.nav", buyerAddress: BUYER, priceSats: 13_000n, depositAddress: depositAddressAt(7, { hdNode: node }), now: 1_700_000_000_000, }); assert.equal(order.status, ORDER_STATUS.quoted); assert.equal(order.name, "sirius.nav"); assert.equal(order.displayName, "sirius.nav"); assert.equal(order.buyerAddress, BUYER); assert.match(order.buyerTokenAddress, /^bchtest:z/); assert.equal(order.mintTxid, null); assert.ok(order.expiresAt > order.createdAt, "a quote lapses — it is not a reservation"); }); test("a .bch order keeps the bare on-chain name but displays the TLD", async () => { const node = await rootNode(); const order = createOrder({ index: 0, name: "sirius.bch", buyerAddress: BUYER, priceSats: 13_000n, depositAddress: depositAddressAt(0, { hdNode: node }), now: 0, }); assert.equal(order.name, "sirius", "on-chain form for .bch is the bare label"); assert.equal(order.displayName, "sirius.bch"); }); // ---------- the mint: who ends up holding the name ---------- test("registrar-funded mint sends the certificate to the buyer and change to the registrar", async () => { const registrar = await BuiltInWallet.fromMnemonic(REGISTRAR_MNEMONIC); const buyerToken = toTokenAddress(BUYER); const built = buildRegistrationTx({ name: "sirius.nav", ownerAddress: buyerToken, ownerTokenAddress: buyerToken, changeAddress: registrar.address, // registrar funded it records: {}, utxos: [{ txid: A, vout: 0, satoshis: 200_000n, address: registrar.address, lockingBytecode: addressToLockingBytecode(registrar.address), }], feeSats: 0n, // buyer already paid off-transaction addressToLockingBytecode, }); const cert = built.transaction.outputs[built.outputMap.certificate]; const change = built.transaction.outputs[built.outputMap.change]; assert.equal( binToHex(cert.lockingBytecode), binToHex(addressToLockingBytecode(buyerToken)), "the certificate must be locked to the BUYER — this is the whole trust argument", ); assert.equal( binToHex(change.lockingBytecode), binToHex(addressToLockingBytecode(registrar.address)), "change returns to the registrar who funded it", ); assert.notEqual(binToHex(cert.lockingBytecode), binToHex(change.lockingBytecode)); // No registrar-controlled output may carry the certificate, at any index. const registrarLock = binToHex(addressToLockingBytecode(registrar.address)); const registrarHoldsToken = built.transaction.outputs.some( (o) => o.token && binToHex(o.lockingBytecode) === registrarLock, ); assert.equal(registrarHoldsToken, false, "the registrar must never hold the name, not even for one block"); // And it is signable by the registrar's key, since the registrar's coins fund it. const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => registrar.keyFor(h)); assert.match(signed.txid, /^[0-9a-f]{64}$/); }); test("no service-fee output is added when the buyer paid off-transaction", async () => { const registrar = await BuiltInWallet.fromMnemonic(REGISTRAR_MNEMONIC); const built = buildRegistrationTx({ name: "sirius.nav", ownerAddress: toTokenAddress(BUYER), changeAddress: registrar.address, utxos: [{ txid: A, vout: 0, satoshis: 200_000n, address: registrar.address, lockingBytecode: addressToLockingBytecode(registrar.address), }], feeSats: 0n, addressToLockingBytecode, }); assert.equal(built.outputMap.serviceFee, undefined, "billing twice would be theft, not a rounding error"); assert.equal(built.costs.serviceFeeSats, 0n); }); test("self-custody builds are unchanged — change still returns to the owner", async () => { const wallet = await BuiltInWallet.fromMnemonic(REGISTRAR_MNEMONIC); const built = buildRegistrationTx({ name: "sirius.nav", ownerAddress: wallet.address, ownerTokenAddress: wallet.tokenAddress, // changeAddress deliberately omitted utxos: [{ txid: A, vout: 0, satoshis: 200_000n, address: wallet.address, lockingBytecode: addressToLockingBytecode(wallet.address), }], feeSats: 5000n, feeAddress: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl", addressToLockingBytecode, }); assert.equal(built.outputMap.change, 0, "change stays at vout 0 so the next registration is self-sufficient"); assert.equal( binToHex(built.transaction.outputs[0].lockingBytecode), binToHex(addressToLockingBytecode(wallet.address)), ); });