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>
302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
#!/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);
|
||
});
|