argus/test/chipnet-escrow-refund.mjs

209 lines
9.9 KiB
JavaScript
Raw Normal View History

// LIVE chipnet test: pay from an exchange, fail to sign, get refunded.
//
// THE SCENARIO
// 1. The buyer starts a purchase. We generate their destination wallet in the
// browser there and then, so the destination is always a key they hold.
// 2. They pay from somewhere else entirely — an exchange withdrawal, which
// cannot sign for anything and cannot receive a CashToken.
// 3. Before minting, they must sign a challenge with the destination key,
// proving they still have the wallet we gave them.
// 4. They never sign — the tab was closed, the phrase was never written down.
// 5. The quote expires and the escrow refunds the payment.
//
// WHAT THIS MUST PROVE
// - `magicworld.x` is NEVER minted. An irrevocable certificate locked to a key
// nobody holds is the worst outcome in the whole product, worse than a failed
// sale, and it is silent.
// - The money goes back to whoever actually paid, recovered from the payment
// transaction rather than from anything the buyer typed.
// - A signature from the wrong key does not open the gate.
//
// Run: node test/chipnet-escrow-refund.mjs
import WebSocket from "ws";
import { readFileSync } from "node:fs";
import { deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic } from "@bitauth/libauth";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { connect, checkAvailability, getBalance } from "../src/lib/registrar.js";
import { loadWallet } from "../src/lib/wallet.js";
import { payerAddressOf, addressToLockingBytecode, broadcast, getUtxos } from "../src/lib/chain.js";
import { signTransaction } from "../src/lib/sign-tx.js";
import { controlChallenge, verifyControl, signMessageBip137 } from "../src/lib/sign-message.js";
import {
quotePrice, bip21, createOrder, depositAddressAt, depositKeyAt,
checkPayment, buildRefundTx, ORDER_STATUS,
} from "../src/lib/orders.js";
import { REGISTRAR } from "../src/registrar-config.js";
const NAME = "magicworld.x";
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, 54 - s.length))}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let client;
try {
client = await connect({ WebSocket });
} catch (e) {
console.error(`\ncannot reach the chain — the test cannot run.\n${e.message}\n`);
process.exit(2);
}
try {
console.log(`Escrow refund on failure-to-sign — live on ${REGISTRAR.network}`);
// ── 1. destination wallet, created for the buyer during checkout ─────────
step(1, "Generate the buyer's destination wallet in-browser");
const buyer = await BuiltInWallet.create();
console.log(` destination : ${buyer.address}`);
check(
(await getBalance(client, buyer.watchedAddresses)).sats === 0n,
"brand-new wallet, usable immediately — the buyer could fund it right now",
);
const avail = await checkAvailability(client, NAME);
check(avail.available, `${NAME} is available before we start`);
if (!avail.available) throw new Error(`${NAME} is already registered — pick another name for this test`);
// ── 2. quote ─────────────────────────────────────────────────────────────
step(2, "Quote the name and open the escrow");
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() * 1_000_000);
const deposit = depositAddressAt(orderIndex, { hdNode: root });
const priceSats = quotePrice({ serviceFeeSats: REGISTRAR.serviceFee.sats });
const order = createOrder({
index: orderIndex, name: NAME, buyerAddress: buyer.address,
priceSats, depositAddress: deposit, now: Date.now(),
});
console.log(` escrow addr : ${deposit}`);
console.log(` price : ${priceSats} sat`);
console.log(` QR : ${bip21({ address: deposit, satoshis: priceSats, message: `Register ${NAME}` })}`);
check(order.status === ORDER_STATUS.quoted, "order starts quoted");
// ── 3. payment from an "exchange" ────────────────────────────────────────
step(3, "Buyer pays from an exchange (cannot sign, cannot hold tokens)");
const exchange = await loadWallet("main");
const payment = await exchange.send([{ cashaddr: deposit, value: Number(priceSats), unit: "sat" }]);
console.log(` payment txid : ${payment.txId}`);
let paid = null;
for (let i = 0; i < 25; i++) {
const p = await checkPayment(client, order);
if (p.paid) { paid = p; break; }
process.stdout.write(` watching escrow… ${i + 1}/25\r`);
await sleep(3000);
}
console.log("");
check(!!paid, "escrow received the payment", paid ? `${paid.receivedSats} sat` : "nothing");
order.status = ORDER_STATUS.awaitingSignature;
// ── 4. the gate ──────────────────────────────────────────────────────────
step(4, "Gate the mint on proof the buyer still holds the destination key");
const challenge = controlChallenge({ name: NAME, destination: buyer.address, orderIndex });
console.log(` challenge : ${challenge}`);
// The buyer could sign — the key is right there in their browser. Prove the
// gate opens for the right key, so a later failure is meaningful.
const goodSig = signMessageBip137(challenge, buyer.receive[0].privateKey);
check(
verifyControl({ message: challenge, signature: goodSig, expectedAddress: buyer.address }).ok,
"the real destination key opens the gate",
);
// Someone else's key must not.
const impostor = await BuiltInWallet.create();
const badSig = signMessageBip137(challenge, impostor.receive[0].privateKey);
const badResult = verifyControl({ message: challenge, signature: badSig, expectedAddress: buyer.address });
check(!badResult.ok, "a different key does NOT open the gate", badResult.reason);
// Neither does a signature for a different order.
const replay = signMessageBip137(
controlChallenge({ name: NAME, destination: buyer.address, orderIndex: orderIndex + 1 }),
buyer.receive[0].privateKey,
);
check(
!verifyControl({ message: challenge, signature: replay, expectedAddress: buyer.address }).ok,
"a signature from another order cannot be replayed",
);
// ── 5. …but this buyer never signs ───────────────────────────────────────
step(5, "The buyer never signs — tab closed, phrase never saved");
const submitted = null; // nothing arrives before the deadline
const gateOpen = submitted
? verifyControl({ message: challenge, signature: submitted, expectedAddress: buyer.address }).ok
: false;
check(!gateOpen, "the mint gate stays shut");
// ── 6. expire and refund ─────────────────────────────────────────────────
step(6, "Quote expires — escrow refunds the payer");
order.expiresAt = Date.now() - 1; // deadline reached
check(Date.now() > order.expiresAt, "quote has expired");
order.status = ORDER_STATUS.refunding;
// Where does the money go? Read it off the payment, not off anything typed.
const payerAddress = await payerAddressOf(client, payment.txId);
console.log(` payer (recovered from the payment): ${payerAddress}`);
check(!!payerAddress, "payer address recovered from the payment transaction");
check(
payerAddress === exchange.cashaddr,
"recovered address matches the actual sender",
`expected ${exchange.cashaddr}`,
);
const depositKey = depositKeyAt(orderIndex, root);
const escrowUtxos = await getUtxos(client, deposit);
const refund = buildRefundTx({
utxos: escrowUtxos,
toAddress: payerAddress,
addressToLockingBytecode,
});
const signedRefund = signTransaction(refund.transaction, refund.sourceOutputs, (h) =>
h === Buffer.from(depositKey.lockingBytecode).toString("hex")
? { privateKey: depositKey.privateKey, publicKey: depositKey.publicKey }
: null,
);
const refundTxid = await broadcast(client, signedRefund.hex);
order.status = ORDER_STATUS.refunded;
console.log(` refund txid : ${refundTxid}`);
console.log(` refunded : ${refund.costs.refundedSats} sat (fee ${refund.costs.chainFeeSats})`);
let emptied = false;
for (let i = 0; i < 25; i++) {
const p = await checkPayment(client, order);
if (p.receivedSats === 0n) { emptied = true; break; }
process.stdout.write(` waiting for the escrow to drain… ${i + 1}/25\r`);
await sleep(3000);
}
console.log("");
check(emptied, "escrow is empty — the money left");
check(
refund.costs.refundedSats > priceSats - 1000n,
"buyer got back essentially everything",
`${refund.costs.refundedSats} of ${priceSats} sat`,
);
// ── 7. the thing that must never have happened ───────────────────────────
step(7, "Verify the name was never minted");
const after = await checkAvailability(client, NAME);
check(after.available, `${NAME} is STILL unregistered — no certificate was created`);
check(
(await getBalance(client, buyer.watchedAddresses)).certificates.length === 0,
"the buyer's wallet holds no certificate",
);
step("=", failures === 0 ? "RESULT: ALL CHECKS PASSED" : `RESULT: ${failures} CHECK(S) FAILED`);
console.log(` unsigned purchase refunded to ${payerAddress}`);
console.log(` ${NAME} never left the shelf`);
} finally {
client.close();
}
process.exit(failures === 0 ? 0 : 1);