argus/test/chipnet-paybyqr.mjs

189 lines
9.1 KiB
JavaScript
Raw Normal View History

// 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);