deviant/scripts/chipnet-whitelist-test.mjs
walerikus 599202a721 chore: checkpoint working tree — shell Tier-B retirement + accumulated WIP
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>
2026-06-17 01:01:37 +02:00

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