argus/test/orders.test.mjs

269 lines
10 KiB
JavaScript
Raw Normal View History

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