deviant/scripts/chipnet-protocol-bounds.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

302 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* chipnet-protocol-bounds.mjs — characterize what the *protocol* (canonical
* CashScript contract + BCH consensus) actually enforces, vs. what the
* Deviant wallet enforces in addition.
*
* The wallet's validateMetadata caps fee at 500 bps and refuses rates
* below 1000. A hostile actor with their own wallet can bypass those
* caps. The question is — what does the canonical contract *itself*
* enforce at the consensus layer?
*
* This script IS that hostile wallet: it talks directly to the canonical
* artifact and BCH chipnet, with no validateMetadata in the path. Four
* adversarial test cases:
*
* A) rate=100, fee=0 — sub-floor rate (wallet would reject)
* B) rate=1000, fee=50% — moderate hostile fee
* C) rate=1000, fee=100% — founder takes the entire backing as fee
* D) rate=1000, fee=1000% — fee is 10× the backing
*
* For each: deploy + claim + record what actually happens.
*
* Founder = Wallet C. Claimer = Wallet C (same wallet, to avoid budget
* coordination across many test cases — the protocol question doesn't
* depend on who the claimer is).
*
* No subprocesses. All work in one Node process so errors surface cleanly.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import {
Contract,
ElectrumNetworkProvider,
TransactionBuilder,
SignatureTemplate,
} from 'cashscript';
const ROOT = join(import.meta.dirname, '..');
const STATE_FILE = join(ROOT, 'ChipnetTests', '.chipnet-mut-state.json');
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT.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 TEST_CASES = [
{ label: 'pb-rate100', rate: 100n, feeBps: 0n, claimAmount: 10n,
purpose: 'sub-floor rate — wallet would reject, does protocol accept?' },
{ label: 'pb-50pc', rate: 1000n, feeBps: 5000n, claimAmount: 55n,
purpose: '50% fee — wallet would reject (cap is 500 bps)' },
{ label: 'pb-100pc', rate: 1000n, feeBps: 10000n, claimAmount: 55n,
purpose: '100% fee — founder takes entire backing as fee' },
{ label: 'pb-1000pc', rate: 1000n, feeBps: 100000n, claimAmount: 55n,
purpose: '1000% fee — fee is 10× the backing' },
];
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 loadState() {
if (!existsSync(STATE_FILE)) return { contracts: {}, events: [] };
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
}
function saveState(s) { writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)); }
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];
}
async function deploy(founder, artifact, provider, params, label, state) {
if (state.contracts[label]) {
console.log(` already deployed: ${state.contracts[label].contractTokenAddress}`);
return state.contracts[label];
}
const utxo = await ensureEligibleUtxo(founder);
const categoryId = utxo.txid;
const tokenCategory = txidToConsensusBytes(categoryId);
const founderLockingBytecode = p2pkhLockingBytecode(founder.publicKeyHash);
const contract = new Contract(
artifact,
[tokenCategory, params.rate, params.feeBps, founderLockingBytecode],
{ provider },
);
console.log(` deploying ${label}: rate=${params.rate}, feeBps=${params.feeBps}`);
console.log(` address: ${contract.tokenAddress}`);
console.log(` category: ${categoryId}`);
const result = await founder.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}`);
const entry = {
label,
founderWallet: 'C',
founderAddress: founder.cashaddr,
founderLockingBytecodeHex: binToHex(founderLockingBytecode),
rateSats: params.rate.toString(),
feeBps: params.feeBps.toString(),
categoryId,
contractAddress: contract.address,
contractTokenAddress: contract.tokenAddress,
genesisTxId: txId,
createdAt: new Date().toISOString(),
};
state.contracts[label] = entry;
state.events.push({ action: 'deploy', label, feeBps: params.feeBps.toString(), rate: params.rate.toString(), txid: txId });
saveState(state);
await sleep(10_000);
return entry;
}
async function claim(claimer, artifact, provider, contractState, amount, state) {
const tokenCategory = txidToConsensusBytes(contractState.categoryId);
const founderLockingBytecode = hexToBin(contractState.founderLockingBytecodeHex);
const rateSats = BigInt(contractState.rateSats);
const feeBps = BigInt(contractState.feeBps);
const contract = new Contract(
artifact, [tokenCategory, rateSats, feeBps, founderLockingBytecode], { provider }
);
if (contract.tokenAddress !== contractState.contractTokenAddress)
throw new Error('contract address mismatch');
const sigTemplate = new SignatureTemplate(claimer.privateKey);
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
const reservoir = reservoirUtxos.find(
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === contractState.categoryId && u.token?.amount > 0n,
);
if (!reservoir) throw new Error('reservoir not found');
const grossSats = amount * rateSats;
const feeSats = (grossSats * feeBps) / 10_000n;
console.log(` claim: ${amount} units → gross=${grossSats}, fee=${feeSats}`);
if (feeBps > 0n && feeSats < 546n)
console.log(` (note: feeSats=${feeSats} < 546 — contract WILL reject at consensus)`);
const userUtxos = await provider.getUtxos(claimer.cashaddr);
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
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 estimateFee(pickedCount) {
const sz = 200n + BigInt(pickedCount + 1) * 150n + (feeBps > 0n ? 4n : 3n) * 40n;
return (sz * 3n) / 2n;
}
let { picked, total, target } = pick(5000n);
let minerFee = estimateFee(picked.length);
if (minerFee > 5000n) {
({ picked, total, target } = pick(minerFee));
minerFee = estimateFee(picked.length);
({ picked, total, target } = pick(minerFee));
}
if (total < target) throw new Error(`claimer BCH ${total} < ${target} needed (picked ${picked.length}, miner ${minerFee})`);
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - feeSats - minerFee;
if (change < 546n) throw new Error(`change ${change} < dust`);
const recipientLockingBytecode = p2pkhLockingBytecode(claimer.publicKeyHash);
const reservoirRemaining = 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, sigTemplate.unlockP2PKH());
tb.addOutput({
to: contract.tokenAddress, amount: 1000n,
token: { amount: reservoirRemaining, category: contractState.categoryId,
nft: { capability: 'minting', commitment: '' } },
});
tb.addOutput({
to: claimer.tokenaddr, amount: grossSats,
token: { amount, category: contractState.categoryId },
});
if (feeSats > 0n) tb.addOutput({ to: contractState.founderAddress, amount: feeSats });
tb.addOutput({ to: claimer.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
console.log(` claim TXID: ${txid}`);
contractState.claims = contractState.claims ?? [];
contractState.claims.push({ user: 'C', amount: amount.toString(), grossSats: grossSats.toString(), feeSats: feeSats.toString(), minerFee: minerFee.toString(), txid });
state.events.push({ action: 'claim', label: contractState.label, user: 'C', amount: amount.toString(), txid });
saveState(state);
await sleep(10_000);
return { txid, grossSats, feeSats, minerFee };
}
async function main() {
const { wif } = JSON.parse(readFileSync(KEYS.c, 'utf8'));
const wallet = await TestNetWallet.fromWIF(wif);
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const provider = new ElectrumNetworkProvider('chipnet');
const state = loadState();
divider();
console.log('PROTOCOL-BOUNDS TEST — what does the canonical contract actually enforce?');
console.log('Founder + Claimer: Wallet C (same wallet). No validateMetadata in path.');
divider();
const results = [];
for (const tc of TEST_CASES) {
console.log('');
divider('-');
console.log(`Case ${tc.label}: ${tc.purpose}`);
divider('-');
let deployResult = null;
let claimResult = null;
let deployError = null;
let claimError = null;
try {
deployResult = await deploy(wallet, artifact, provider, { rate: tc.rate, feeBps: tc.feeBps }, tc.label, state);
} catch (err) {
deployError = err.message;
console.log(` DEPLOY FAILED: ${err.message}`);
}
if (deployResult) {
try {
claimResult = await claim(wallet, artifact, provider, deployResult, tc.claimAmount, state);
} catch (err) {
claimError = err.message;
console.log(` CLAIM FAILED: ${err.message}`);
}
}
results.push({ ...tc, deployResult, claimResult, deployError, claimError });
}
console.log('');
divider();
console.log('PROTOCOL-BOUNDS — SUMMARY');
divider();
for (const r of results) {
const deployStatus = r.deployError ? `FAILED — ${r.deployError}` : `OK (${r.deployResult.genesisTxId.slice(0, 16)}...)`;
const claimStatus = r.claimError
? `FAILED — ${r.claimError}`
: r.claimResult
? `OK (${r.claimResult.txid.slice(0, 16)}..., fee paid ${r.claimResult.feeSats}, miner ${r.claimResult.minerFee})`
: 'skipped';
console.log(` ${r.label} (rate=${r.rate}, feeBps=${r.feeBps}):`);
console.log(` deploy: ${deployStatus}`);
console.log(` claim: ${claimStatus}`);
}
divider();
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});