Snapshot of accumulated work-in-progress on top of the baseline commit. This session — retire Tier B from the wallet shell (single-tier model, glossary "Tiers & trust"): - SendFlow.tsx: BCH-only; removed the non-functional Tier-B send path, the D-4/D-5 raw-BCH co-display, and the ADR-0013 friction ladder. - ArtemisionScreen.tsx: Create wizard collapsed to a single 5-step on-chain genesis flow; tier-selection step + Tier-B branch (prepareTierBImport) deleted. - QuickActions.tsx / SettingsScreen.tsx: receive note reworded; "Send friction thresholds" + "Tier explainer" rows removed; registry-trust note reworded. - Verified: tsc clean, vitest 74/74, full preview walkthrough, no console errors. Pre-existing uncommitted work also folded in: - USDm -> FIATm family rename/reorg (Parameters/FIATm/, design records) - ~130 chipnet + sim-fiatm scripts (catalytic-converter, hyperinflation backtests) - snapshots 0153-0168; wiki updates (glossary, decisions, index); ADR-0044/0045 - mockups + additional shell screens (Agora, Apps, MainScreen, AssetDetail, ...) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
379 lines
19 KiB
JavaScript
379 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* chipnet-whitelist-test.mjs — T-001 variant: two whitelisted backdoor
|
|
* addresses + canonical claim() camouflage.
|
|
*
|
|
* Scenario the user posed: a malicious founder ships a contract that
|
|
* - keeps a canonical pegged claim() for ordinary users
|
|
* - lets THEMSELVES execute "favorable swaps" (no peg)
|
|
* - lets a SECOND whitelisted address do the same
|
|
*
|
|
* This single-process script does it end-to-end on chipnet:
|
|
* 1. Founder (Wallet C) genesises the whitelist contract, naming
|
|
* themselves AND Wallet B as the two whitelisted addresses.
|
|
* 2. Wallet A (public, no privilege) does a CANONICAL claim from
|
|
* claim() — pays the full peg, gets a fully-backed FT UTXO.
|
|
* 3. Wallet C calls founderFreeSwap() — gets FTs backed by dust only.
|
|
* 4. Wallet B calls privilegedFreeSwap() — same favorable terms,
|
|
* same fundamental fraud as #3.
|
|
* 5. Each resulting FT UTXO is fed through the production
|
|
* src/verify/ module:
|
|
* - D-1 (verifyContractCanonical) on the token state
|
|
* - D-2 (verifyUtxoBacking) on each circulating UTXO
|
|
* Expected: D-1 flags non-canonical (one verdict for the token);
|
|
* D-2 flags two of three UTXOs as underbacked (the founder's and
|
|
* the privileged's); the public claim's UTXO comes back fully-backed.
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { TestNetWallet } from 'mainnet-js';
|
|
import {
|
|
Contract,
|
|
ElectrumNetworkProvider,
|
|
TransactionBuilder,
|
|
SignatureTemplate,
|
|
} from 'cashscript';
|
|
import { verifyContractCanonical } from '../src/verify/contract.ts';
|
|
import { verifyUtxoBacking } from '../src/verify/backing.ts';
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
const STATE_FILE = join(ROOT, 'ChipnetTests', '.chipnet-whitelist-test.json');
|
|
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT_Whitelist.json');
|
|
|
|
const KEYS = {
|
|
a: join(ROOT, '.chipnet-keys.json'),
|
|
b: join(ROOT, '.chipnet-keys-b.json'),
|
|
c: join(ROOT, '.chipnet-keys-c.json'),
|
|
};
|
|
const RESERVOIR_FT_SUPPLY = 1_000_000_000_000_000_000n;
|
|
const RATE_SATS = 1000n;
|
|
const FEE_BPS = 100n; // 1% — the published peg
|
|
|
|
function hexToBin(hex) {
|
|
const out = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
return out;
|
|
}
|
|
function txidToConsensusBytes(txidHex) {
|
|
const display = hexToBin(txidHex);
|
|
const out = new Uint8Array(display.length);
|
|
for (let i = 0; i < display.length; i++) out[i] = display[display.length - 1 - i];
|
|
return out;
|
|
}
|
|
function binToHex(bin) {
|
|
return Array.from(bin, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
function p2pkhLockingBytecode(pkh) {
|
|
const out = new Uint8Array(25);
|
|
out[0] = 0x76; out[1] = 0xa9; out[2] = 0x14;
|
|
out.set(pkh, 3);
|
|
out[23] = 0x88; out[24] = 0xac;
|
|
return out;
|
|
}
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
function divider(ch = '=') { console.log(ch.repeat(80)); }
|
|
function step(n, title) { console.log(''); divider(); console.log(`STEP ${n} — ${title}`); divider(); }
|
|
|
|
async function ensureEligibleUtxo(wallet) {
|
|
let utxos = await wallet.getUtxos();
|
|
let eligible = utxos.filter((u) => u.vout === 0 && !u.token);
|
|
if (eligible.length > 0) return eligible[0];
|
|
console.log(' (splitter: no vout=0 plain-BCH UTXO; carving one)');
|
|
const ineligible = utxos.filter((u) => !(u.vout === 0 && !u.token));
|
|
const result = await wallet.send(
|
|
[{ cashaddr: wallet.cashaddr, value: 2000n, unit: 'sat' }],
|
|
ineligible.length > 0 ? { utxoIds: ineligible } : undefined,
|
|
);
|
|
console.log(` splitter TXID: ${result?.txId ?? result?.txid}`);
|
|
await sleep(12_000);
|
|
utxos = await wallet.getUtxos();
|
|
eligible = utxos.filter((u) => u.vout === 0 && !u.token);
|
|
if (eligible.length === 0) throw new Error('splitter did not produce eligible UTXO');
|
|
return eligible[0];
|
|
}
|
|
|
|
// Multi-input picker with self-correcting miner fee, copied from chipnet-mut-claim.
|
|
function pickAndEstimate(bchUtxos, grossSats, feeSats, hasFeeOutput) {
|
|
function pick(minerFee) {
|
|
const target = grossSats + feeSats + minerFee + 1000n;
|
|
const picked = []; let total = 0n;
|
|
for (const u of bchUtxos) { picked.push(u); total += BigInt(u.satoshis); if (total >= target) break; }
|
|
return { picked, total, target };
|
|
}
|
|
function estFee(n) {
|
|
const sz = 200n + BigInt(n + 1) * 150n + (hasFeeOutput ? 4n : 3n) * 40n;
|
|
return (sz * 3n) / 2n;
|
|
}
|
|
let { picked, total, target } = pick(5000n);
|
|
let mf = estFee(picked.length);
|
|
if (mf > 5000n) {
|
|
({ picked, total, target } = pick(mf));
|
|
mf = estFee(picked.length);
|
|
({ picked, total, target } = pick(mf));
|
|
}
|
|
return { picked, total, target, minerFee: mf };
|
|
}
|
|
|
|
async function main() {
|
|
const { wif: wifA } = JSON.parse(readFileSync(KEYS.a, 'utf8'));
|
|
const { wif: wifB } = JSON.parse(readFileSync(KEYS.b, 'utf8'));
|
|
const { wif: wifC } = JSON.parse(readFileSync(KEYS.c, 'utf8'));
|
|
const founderC = await TestNetWallet.fromWIF(wifC);
|
|
const privilegedB = await TestNetWallet.fromWIF(wifB);
|
|
const publicA = await TestNetWallet.fromWIF(wifA);
|
|
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
|
|
const provider = new ElectrumNetworkProvider('chipnet');
|
|
|
|
let state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : {};
|
|
|
|
// ------------------------------------------------------------------
|
|
step(1, 'Founder C deploys the whitelist contract');
|
|
// ------------------------------------------------------------------
|
|
// Constructor: (tokenCategory, backingRateSats, founderFeeBps,
|
|
// founderLockingBytecode, founderPk, privilegedPk)
|
|
// Founder fee output goes to C; founderPk = C's pubkey; privilegedPk = B's pubkey.
|
|
let entry = state.contract;
|
|
if (!entry) {
|
|
const utxo = await ensureEligibleUtxo(founderC);
|
|
const categoryId = utxo.txid;
|
|
const tokenCategory = txidToConsensusBytes(categoryId);
|
|
const founderLockingBytecode = p2pkhLockingBytecode(founderC.publicKeyHash);
|
|
const contract = new Contract(
|
|
artifact,
|
|
[tokenCategory, RATE_SATS, FEE_BPS, founderLockingBytecode, founderC.publicKeyCompressed, privilegedB.publicKeyCompressed],
|
|
{ provider },
|
|
);
|
|
console.log(` rate=${RATE_SATS}, feeBps=${FEE_BPS}`);
|
|
console.log(` founderPk (C): ${binToHex(founderC.publicKeyCompressed)}`);
|
|
console.log(` privilegedPk (B): ${binToHex(privilegedB.publicKeyCompressed)}`);
|
|
console.log(` contract address: ${contract.tokenAddress}`);
|
|
console.log(` category: ${categoryId}`);
|
|
const result = await founderC.tokenGenesis(
|
|
{ cashaddr: contract.tokenAddress, amount: RESERVOIR_FT_SUPPLY, value: 1000n,
|
|
nft: { capability: 'minting', commitment: '' } },
|
|
[],
|
|
{ utxoIds: [utxo] },
|
|
);
|
|
const txId = result?.txId ?? result?.txid;
|
|
console.log(` genesis TXID: ${txId}`);
|
|
entry = {
|
|
categoryId, contractAddress: contract.address, contractTokenAddress: contract.tokenAddress,
|
|
founderLockingBytecodeHex: binToHex(founderLockingBytecode),
|
|
founderPkHex: binToHex(founderC.publicKeyCompressed),
|
|
privilegedPkHex: binToHex(privilegedB.publicKeyCompressed),
|
|
rateSats: RATE_SATS.toString(), feeBps: FEE_BPS.toString(),
|
|
founderAddress: founderC.cashaddr,
|
|
genesisTxId: txId,
|
|
};
|
|
state.contract = entry;
|
|
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
await sleep(10_000);
|
|
} else {
|
|
console.log(` (already deployed: ${entry.contractTokenAddress})`);
|
|
}
|
|
|
|
// Helper: reconstruct contract from saved state
|
|
const tokenCategory = txidToConsensusBytes(entry.categoryId);
|
|
const founderLockingBytecode = hexToBin(entry.founderLockingBytecodeHex);
|
|
const contract = new Contract(
|
|
artifact,
|
|
[tokenCategory, RATE_SATS, FEE_BPS, founderLockingBytecode, hexToBin(entry.founderPkHex), hexToBin(entry.privilegedPkHex)],
|
|
{ provider },
|
|
);
|
|
if (contract.tokenAddress !== entry.contractTokenAddress) throw new Error('contract address mismatch');
|
|
|
|
state.claims = state.claims ?? {};
|
|
|
|
// ------------------------------------------------------------------
|
|
step(2, 'Public user A claims 55 units via CANONICAL claim() — pays the peg');
|
|
// ------------------------------------------------------------------
|
|
if (!state.claims.publicA) {
|
|
const amount = 55n;
|
|
const grossSats = amount * RATE_SATS;
|
|
const feeSats = (grossSats * FEE_BPS) / 10000n;
|
|
const recipientLockingBytecode = p2pkhLockingBytecode(publicA.publicKeyHash);
|
|
const sigA = new SignatureTemplate(publicA.privateKey);
|
|
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
|
|
const reservoir = reservoirUtxos.find((u) => u.token?.nft?.capability === 'minting' && u.token?.category === entry.categoryId && u.token?.amount > 0n);
|
|
if (!reservoir) throw new Error('reservoir not found');
|
|
const userUtxos = await provider.getUtxos(publicA.cashaddr);
|
|
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
|
|
const { picked, total, target, minerFee } = pickAndEstimate(bchUtxos, grossSats, feeSats, true);
|
|
if (total < target) throw new Error(`A BCH ${total} < ${target}`);
|
|
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - feeSats - minerFee;
|
|
const remaining = reservoir.token.amount - amount;
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(reservoir, contract.unlock.claim(amount, recipientLockingBytecode));
|
|
for (const u of picked) tb.addInput(u, sigA.unlockP2PKH());
|
|
tb.addOutput({ to: contract.tokenAddress, amount: 1000n,
|
|
token: { amount: remaining, category: entry.categoryId, nft: { capability: 'minting', commitment: '' } } });
|
|
tb.addOutput({ to: publicA.tokenaddr, amount: grossSats,
|
|
token: { amount, category: entry.categoryId } });
|
|
tb.addOutput({ to: entry.founderAddress, amount: feeSats });
|
|
tb.addOutput({ to: publicA.cashaddr, amount: change });
|
|
console.log(` A pays gross=${grossSats} + fee=${feeSats} + miner=${minerFee}`);
|
|
const sent = await tb.send();
|
|
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
|
|
console.log(` claim TXID: ${txid}`);
|
|
state.claims.publicA = { user: 'A', amount: amount.toString(), grossSats: grossSats.toString(), feeSats: feeSats.toString(), txid };
|
|
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
await sleep(10_000);
|
|
} else {
|
|
console.log(` (already done: ${state.claims.publicA.txid})`);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
step(3, 'Founder C calls founderFreeSwap → underbacked FT to C');
|
|
// ------------------------------------------------------------------
|
|
if (!state.claims.founderBackdoor) {
|
|
const amount = 1_000_000n; // 1M FTs, peg would require 1B sats; we attach dust
|
|
const sigC = new SignatureTemplate(founderC.privateKey);
|
|
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
|
|
const reservoir = reservoirUtxos.find((u) => u.token?.nft?.capability === 'minting' && u.token?.category === entry.categoryId && u.token?.amount > 0n);
|
|
if (!reservoir) throw new Error('reservoir not found');
|
|
const userUtxos = await provider.getUtxos(founderC.cashaddr);
|
|
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
|
|
const lootSats = 675n;
|
|
const minerFee = 5000n;
|
|
const target = lootSats + minerFee + 1000n;
|
|
const picked = []; let total = 0n;
|
|
for (const u of bchUtxos) { picked.push(u); total += BigInt(u.satoshis); if (total >= target) break; }
|
|
if (total < target) throw new Error(`C BCH ${total} < ${target}`);
|
|
const change = BigInt(reservoir.satoshis) + total - 1000n - lootSats - minerFee;
|
|
const recipientLockingBytecode = p2pkhLockingBytecode(founderC.publicKeyHash);
|
|
const remaining = reservoir.token.amount - amount;
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(reservoir, contract.unlock.founderFreeSwap(sigC, amount, recipientLockingBytecode));
|
|
for (const u of picked) tb.addInput(u, sigC.unlockP2PKH());
|
|
tb.addOutput({ to: contract.tokenAddress, amount: 1000n,
|
|
token: { amount: remaining, category: entry.categoryId, nft: { capability: 'minting', commitment: '' } } });
|
|
tb.addOutput({ to: founderC.tokenaddr, amount: lootSats,
|
|
token: { amount, category: entry.categoryId } });
|
|
tb.addOutput({ to: founderC.cashaddr, amount: change });
|
|
console.log(` C extracts ${amount} FT for only ${lootSats} sats backing (peg would require ${amount * RATE_SATS})`);
|
|
const sent = await tb.send();
|
|
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
|
|
console.log(` founderFreeSwap TXID: ${txid}`);
|
|
state.claims.founderBackdoor = { user: 'C', amount: amount.toString(), lootSats: lootSats.toString(), txid };
|
|
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
await sleep(10_000);
|
|
} else {
|
|
console.log(` (already done: ${state.claims.founderBackdoor.txid})`);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
step(4, 'Whitelisted B calls privilegedFreeSwap → underbacked FT to B');
|
|
// ------------------------------------------------------------------
|
|
if (!state.claims.privilegedBackdoor) {
|
|
const amount = 500_000n;
|
|
const sigB = new SignatureTemplate(privilegedB.privateKey);
|
|
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
|
|
const reservoir = reservoirUtxos.find((u) => u.token?.nft?.capability === 'minting' && u.token?.category === entry.categoryId && u.token?.amount > 0n);
|
|
if (!reservoir) throw new Error('reservoir not found');
|
|
const userUtxos = await provider.getUtxos(privilegedB.cashaddr);
|
|
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
|
|
const lootSats = 675n;
|
|
const minerFee = 8000n; // higher because B has many small UTXOs
|
|
const target = lootSats + minerFee + 1000n;
|
|
const picked = []; let total = 0n;
|
|
for (const u of bchUtxos) { picked.push(u); total += BigInt(u.satoshis); if (total >= target) break; }
|
|
if (total < target) throw new Error(`B BCH ${total} < ${target}`);
|
|
const change = BigInt(reservoir.satoshis) + total - 1000n - lootSats - minerFee;
|
|
const recipientLockingBytecode = p2pkhLockingBytecode(privilegedB.publicKeyHash);
|
|
const remaining = reservoir.token.amount - amount;
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(reservoir, contract.unlock.privilegedFreeSwap(sigB, amount, recipientLockingBytecode));
|
|
for (const u of picked) tb.addInput(u, sigB.unlockP2PKH());
|
|
tb.addOutput({ to: contract.tokenAddress, amount: 1000n,
|
|
token: { amount: remaining, category: entry.categoryId, nft: { capability: 'minting', commitment: '' } } });
|
|
tb.addOutput({ to: privilegedB.tokenaddr, amount: lootSats,
|
|
token: { amount, category: entry.categoryId } });
|
|
tb.addOutput({ to: privilegedB.cashaddr, amount: change });
|
|
console.log(` B extracts ${amount} FT for only ${lootSats} sats backing (peg would require ${amount * RATE_SATS})`);
|
|
const sent = await tb.send();
|
|
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
|
|
console.log(` privilegedFreeSwap TXID: ${txid}`);
|
|
state.claims.privilegedBackdoor = { user: 'B', amount: amount.toString(), lootSats: lootSats.toString(), txid };
|
|
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
await sleep(10_000);
|
|
} else {
|
|
console.log(` (already done: ${state.claims.privilegedBackdoor.txid})`);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
step(5, 'D-1 — does the wallet flag the contract as non-canonical?');
|
|
// ------------------------------------------------------------------
|
|
const d1 = verifyContractCanonical({
|
|
categoryId: entry.categoryId,
|
|
backingRateSats: RATE_SATS,
|
|
feeBps: FEE_BPS,
|
|
founderLockingBytecode,
|
|
onChainContractTokenAddress: entry.contractTokenAddress,
|
|
});
|
|
console.log(` D-1 verdict: ${d1.status}`);
|
|
console.log(` canonical-template address (published params): ${d1.canonicalAddress}`);
|
|
console.log(` on-chain whitelist contract address: ${d1.onChainAddress}`);
|
|
console.log(` equal? ${d1.canonicalAddress === d1.onChainAddress}`);
|
|
if (d1.status !== 'non-canonical') {
|
|
console.log(' UNEXPECTED — D-1 should have flagged non-canonical');
|
|
} else {
|
|
console.log(' ✓ D-1 caught the whitelist contract — peg flagged "not guaranteed"');
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
step(6, 'D-2 — does the wallet flag each underbacked UTXO?');
|
|
// ------------------------------------------------------------------
|
|
async function checkBackingFor(wallet, who) {
|
|
const utxos = await provider.getUtxos(wallet.cashaddr);
|
|
const ftUtxos = utxos.filter(
|
|
(u) => u.token?.category === entry.categoryId && !u.token?.nft && u.token?.amount > 0n,
|
|
);
|
|
console.log(` ${who}: ${ftUtxos.length} FT UTXO(s) of this category`);
|
|
for (const u of ftUtxos) {
|
|
const verdict = verifyUtxoBacking({
|
|
utxoSatoshis: BigInt(u.satoshis),
|
|
utxoTokenAmount: u.token.amount,
|
|
backingRateSats: RATE_SATS,
|
|
});
|
|
console.log(` UTXO ${u.txid.slice(0, 16)}:${u.vout} — ${u.token.amount} FT, ${u.satoshis} sats`);
|
|
console.log(` peg requires ${verdict.expectedSats} sats → ${verdict.status.toUpperCase()}`);
|
|
if (verdict.status === 'underbacked') {
|
|
console.log(` shortfall ${verdict.shortfallSats} sats (carries ${verdict.backingBps} bps of peg)`);
|
|
}
|
|
}
|
|
return ftUtxos;
|
|
}
|
|
await checkBackingFor(publicA, 'Wallet A (public)');
|
|
await checkBackingFor(founderC, 'Wallet C (founder)');
|
|
await checkBackingFor(privilegedB, 'Wallet B (privileged)');
|
|
|
|
// ------------------------------------------------------------------
|
|
step(7, 'Summary');
|
|
// ------------------------------------------------------------------
|
|
console.log('Findings:');
|
|
console.log(' - Whitelist contract DEPLOYED and OPERATES at consensus: any number of');
|
|
console.log(' backdoor functions can be added to a contract, gated on any pubkey set.');
|
|
console.log(' - Three signing parties produced three different outcomes:');
|
|
console.log(' A (public) → CANONICAL claim() path → fully-backed UTXO');
|
|
console.log(' C (founder) → founderFreeSwap() → UTXO backed by dust only');
|
|
console.log(' B (privileged) → privilegedFreeSwap() → UTXO backed by dust only');
|
|
console.log(' - D-1 flagged the contract as non-canonical: identical to T-001, the');
|
|
console.log(' extra bytecode (2 backdoor functions + extra constructor params + extra');
|
|
console.log(' checkSig opcodes) produces a different P2SH32 address than the canonical');
|
|
console.log(' template instantiated with the published params would yield.');
|
|
console.log(' - D-2 flagged 2 of 3 UTXOs: the backdoor outputs carry dust against');
|
|
console.log(' 1-million-sat and 500-million-sat peg requirements. A\'s canonical');
|
|
console.log(' UTXO passes D-2 cleanly.');
|
|
console.log('');
|
|
console.log('Conclusion: whitelist backdoor is a T-001 subspecies. The defenses');
|
|
console.log(' (D-1, D-2) catch it without modification. The protocol does NOT prevent');
|
|
console.log(' founders from writing such contracts — it relies on the wallet to');
|
|
console.log(' reject anything that does not match the canonical template.');
|
|
divider();
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('FAILED:', err?.message ?? err);
|
|
if (err?.stack) console.error(err.stack);
|
|
process.exit(1);
|
|
});
|