Snapshot of the decentralized-web stack at the point of the resolver+Theseus rebuild deploy. Includes: - Argus (BNS engine + resolver daemon + Sia gateway) - AriadneResolver (Windows Inno installer bundle + Android APK sources + Firefox extension) - TheseusNavigator (Electron browser) - site/ (silentmode.st content, deployed to Sia at bns/silentmode/) - design docs, roadmap, protocol spec Secrets excluded via .gitignore: Argus/sia-s3.json, Argus/wallets.json, Argus/ca/*.key,*.crt. Build outputs, node_modules, and bundled runtimes also excluded. Shipped hashes on dl.silentmode.st at this commit: AriadneResolver-Setup-0.1.0.exe 5bcb216eef31ea28ed767e4134ab74bd5ac69dfbd365fd249e9e6938e55c986a TheseusNavigator-Setup-0.0.1.exe 7c735e88bad2da3347145adba3016c8f626a18b8422289c8c6ba471972e2952b TheseusNavigator-0.0.1-portable.exe 008fd84445babeabb401b2bca40ea9466b24b0e6d6c85104da7640c5c5c84521 ariadne-v0.2.apk 635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
// 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)),
|
|
);
|
|
});
|