// LIVE chipnet check for the pay-by-QR fulfilment path. // // The claim under test is the one the whole trust story rests on: the registrar // funds and signs the minting transaction, yet the certificate lands in a wallet // the registrar has no key for, in that same transaction — so there is no moment // at which the registrar could walk off with the name. // // The "buyer" here is a throwaway wallet that is never funded. That is the point: // in this flow a buyer needs no coins in the destination wallet, no connector, // and no CashTokens support beyond being able to receive. // // Run: node test/chipnet-paybyqr.mjs import WebSocket from "ws"; import { BuiltInWallet } from "../src/lib/wallet-web.js"; import { connect, mintForBuyer, findCertificateUtxo, getBalance } from "../src/lib/registrar.js"; import { resolveName } from "../src/lib/resolver-web.js"; import { loadWallet } from "../src/lib/wallet.js"; import { quotePrice, bip21, normaliseBuyerAddress, createOrder, depositAddressAt, depositXpub, checkPayment, } from "../src/lib/orders.js"; import { REGISTRAR } from "../src/registrar-config.js"; import { deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic, encodeHdPublicKey } from "@bitauth/libauth"; import { readFileSync } from "node:fs"; const tag = Math.random().toString(16).slice(2, 8); const NAME = `qrtest-${tag}.nav`; let failures = 0; const check = (ok, label, detail = "") => { console.log(` ${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`); if (!ok) failures++; }; const step = (n, s) => console.log(`\n─── ${n}. ${s} ${"─".repeat(Math.max(0, 56 - s.length))}`); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let client; try { client = await connect({ WebSocket }); } catch (e) { console.error(` cannot reach the chain — the test cannot run. ${e.message} `); process.exit(2); // 2 = environment, not a product failure } try { console.log(`Pay-by-QR fulfilment — live on ${REGISTRAR.network}`); // ── 1. the buyer: a wallet with nothing in it ──────────────────────────── step(1, "A buyer who owns no coins and runs no connector"); const buyer = await BuiltInWallet.create(); const buyerBalance = await getBalance(client, buyer.watchedAddresses); console.log(` buyer address : ${buyer.address}`); check(buyerBalance.sats === 0n, "buyer wallet is empty — they only need to receive"); // ── 2. quote + deposit address + QR payload ────────────────────────────── step(2, "Quote the name and hand out a payment address"); const priceSats = quotePrice({ serviceFeeSats: REGISTRAR.serviceFee.sats }); const store = JSON.parse(readFileSync(new URL("../wallets.json", import.meta.url), "utf8")); const root = deriveHdPrivateNodeFromSeed(await deriveSeedFromBip39Mnemonic(store.main.seed)); const orderIndex = Math.floor(Math.random() * 100000); const deposit = depositAddressAt(orderIndex, { hdNode: root }); // The public order service should never hold a private key — prove the same // address comes out of the xpub alone. const encoded = encodeHdPublicKey({ network: "testnet", node: depositXpub(root) }); const xpubString = typeof encoded === "string" ? encoded : encoded.hdPublicKey; check( depositAddressAt(orderIndex, { xpub: xpubString }) === deposit, "deposit address derivable from the xpub alone (no key on the public server)", ); const order = createOrder({ index: orderIndex, name: NAME, buyerAddress: buyer.address, priceSats, depositAddress: deposit, now: Date.now(), }); const uri = bip21({ address: deposit, satoshis: priceSats, label: "Silent Mode", message: `Register ${order.displayName}`, }); console.log(` name : ${order.displayName}`); console.log(` price : ${priceSats} sat`); console.log(` deposit addr : ${deposit}`); console.log(` QR payload : ${uri}`); check(uri.startsWith(deposit), "the QR encodes the order's own address"); const before = await checkPayment(client, order); check(before.paid === false && before.receivedSats === 0n, "nothing received yet"); // ── 3. the buyer pays from some other wallet ───────────────────────────── step(3, "Buyer pays from an unrelated wallet (stand-in: the project wallet)"); const payer = await loadWallet("main"); const payment = await payer.send([{ cashaddr: deposit, value: Number(priceSats), unit: "sat" }]); console.log(` payment txid : ${payment.txId}`); let paid = null; for (let i = 0; i < 20; i++) { const p = await checkPayment(client, order); if (p.paid) { paid = p; break; } process.stdout.write(` watching the deposit address… ${i + 1}/20\r`); await sleep(3000); } console.log(""); check(!!paid, "payment detected on-chain", paid ? `${paid.receivedSats} sat` : "not seen"); check(paid?.shortfallSats === 0n, "paid in full"); // ── 4. fulfilment: registrar mints TO THE BUYER ────────────────────────── step(4, "Registrar mints the certificate to the buyer's address"); // The registrar's minting wallet is its own hot wallet, separate from the // deposit branch. Note it derives on BCH's coin type (m/44'/145'/…) while the // legacy project wallet in wallets.json uses m/44'/0'/… — same seed, different // addresses. Top it up from the project wallet if it has nothing to spend. const registrar = await BuiltInWallet.fromMnemonic(store.main.seed); let registrarBalance = await getBalance(client, registrar.watchedAddresses); if (registrarBalance.sats < 20_000n) { console.log(` registrar hot wallet is low (${registrarBalance.sats} sat) — funding it`); const top = await payer.send([{ cashaddr: registrar.address, value: 100_000, unit: "sat" }]); console.log(` funding txid : ${top.txId}`); for (let i = 0; i < 20; i++) { registrarBalance = await getBalance(client, registrar.watchedAddresses); if (registrarBalance.sats >= 20_000n) break; process.stdout.write(` waiting for registrar funds… ${i + 1}/20\r`); await sleep(3000); } console.log(""); } check(registrarBalance.sats >= 20_000n, "registrar can fund a mint", `${registrarBalance.sats} sat`); const buyerDest = normaliseBuyerAddress(order.buyerAddress).tokenAddress; const result = await mintForBuyer(client, { registrarWallet: registrar, name: order.name, buyerTokenAddress: buyerDest, records: { u: "https://silentmode.st" }, onProgress: (s, d) => console.log(` · ${s}${d ? " " + JSON.stringify(d) : ""}`), }); console.log(` mint txid : ${result.txid}`); console.log(` category : ${result.category}`); // ── 5. who holds the name? ─────────────────────────────────────────────── step(5, "Verify the buyer holds it and the registrar does not"); let cert = null; for (let i = 0; i < 20; i++) { cert = await findCertificateUtxo(client, { addresses: buyer.watchedAddresses, category: result.category, rawName: result.name, }); if (cert) break; process.stdout.write(` waiting for the certificate… ${i + 1}/20\r`); await sleep(3000); } console.log(""); check(!!cert, "certificate sits at the BUYER's address", cert?.address); const registrarHolds = await findCertificateUtxo(client, { addresses: registrar.watchedAddresses, category: result.category, rawName: result.name, }); check(registrarHolds === null, "registrar holds NO certificate for this name"); const buyerAfter = await getBalance(client, buyer.watchedAddresses); check( buyerAfter.certificates.length === 1, "buyer's wallet shows exactly one certificate", `${buyerAfter.certificates.length} found, ${buyerAfter.sats} spendable sat`, ); // ── 6. it resolves ─────────────────────────────────────────────────────── step(6, "The name resolves from the chain"); let entry = null; for (let i = 0; i < 20; i++) { entry = await resolveName(result.displayName, { WebSocket }); if (entry) break; process.stdout.write(` waiting for the resolver… ${i + 1}/20\r`); await sleep(3000); } console.log(""); check(!!entry, `${result.displayName} resolves`, entry ? JSON.stringify(entry.records) : ""); check(entry?.category === result.category, "resolver reports the category we minted"); step("=", failures === 0 ? "RESULT: ALL CHECKS PASSED" : `RESULT: ${failures} CHECK(S) FAILED`); console.log(` ${result.displayName} is owned by ${buyer.address}`); console.log(` the buyer never connected a wallet, and never held a coin before paying`); } finally { client.close(); } process.exit(failures === 0 ? 0 : 1);