deviant/scripts/chipnet-fee-mutability-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

247 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-fee-mutability-test.mjs — empirical proof that the founder fee
* is fixed at genesis and cannot be mutated by any path.
*
* Three angles of attack against a DEPLOYED canonical contract:
* A) ABI inspection — no admin function exists at all
* B) Programmatic admin call — invoking a non-existent function fails
* at the SDK layer before even reaching the network
* C) Per-tx consensus rejection — building a claim() tx that pays the
* founder a DIFFERENT fee than the contract-computed amount → BCH
* consensus rejects the broadcast (zero cost: failed broadcasts
* don't pay miners)
*
* Target: mba-1pc from .chipnet-mut-state.json (canonical contract,
* rate=1000, feeBps=100). Founder = Wallet C. Caller = Wallet C
* (founder caller is the strongest test: even the founder, who has
* every economic motive to pay themselves a different fee, cannot do so).
*
* Costs: zero. Both malformed-fee broadcasts are rejected at consensus
* before mining, so no miner fee is paid.
*/
import { existsSync, readFileSync } 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_C = join(ROOT, '.chipnet-keys-c.json');
const TARGET_LABEL = process.env.LABEL ?? 'mba-1pc';
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 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;
}
function divider(ch = '=') { console.log(ch.repeat(80)); }
function step(n, title) { console.log(''); divider(); console.log(`STEP ${n} — ${title}`); divider(); }
async function tryBroadcast(tb, label, expected) {
try {
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
console.log(` ${label}: BROADCAST ACCEPTED (TXID ${txid}) ← UNEXPECTED`);
return { accepted: true, txid };
} catch (err) {
const msg = err?.message ?? String(err);
const firstLine = msg.split('\n')[0];
console.log(` ${label}: REJECTED — ${firstLine}`);
return { accepted: false, error: firstLine, expected: expected };
}
}
async function main() {
if (!existsSync(STATE_FILE)) throw new Error(`state file missing — run mut-deploy first`);
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
const c = state.contracts?.[TARGET_LABEL];
if (!c) throw new Error(`no contract "${TARGET_LABEL}" in state`);
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const { wif } = JSON.parse(readFileSync(KEYS_C, 'utf8'));
const founder = await TestNetWallet.fromWIF(wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sigTemplate = new SignatureTemplate(founder.privateKey);
const tokenCategory = txidToConsensusBytes(c.categoryId);
const founderLockingBytecode = hexToBin(c.founderLockingBytecodeHex);
const rateSats = BigInt(c.rateSats);
const feeBps = BigInt(c.feeBps);
const contract = new Contract(
artifact, [tokenCategory, rateSats, feeBps, founderLockingBytecode], { provider }
);
if (contract.tokenAddress !== c.contractTokenAddress) throw new Error('contract address mismatch');
console.log(`Target contract: ${TARGET_LABEL}`);
console.log(` address: ${contract.tokenAddress}`);
console.log(` rate: ${rateSats} sats/unit`);
console.log(` feeBps: ${feeBps} (${Number(feeBps) / 100}%)`);
console.log(` founder: ${c.founderAddress} (acting as caller)`);
// ------------------------------------------------------------------
step('A', 'ABI inspection — what admin functions are in the contract?');
// ------------------------------------------------------------------
console.log(` contractName: ${artifact.contractName}`);
console.log(` ABI functions: ${JSON.stringify(artifact.abi.map((f) => f.name))}`);
console.log(` Constructor: ${JSON.stringify(artifact.constructorInputs.map((p) => `${p.name}:${p.type}`))}`);
const adminCandidates = artifact.abi.filter((f) => /update|admin|setfee|setrate|change|migrate|upgrade/i.test(f.name));
console.log(` Admin-like functions: ${adminCandidates.length === 0 ? 'NONE' : adminCandidates.map(f => f.name).join(', ')}`);
console.log(' VERDICT: founderFeeBps is a CONSTRUCTOR parameter — baked into bytecode at genesis.');
console.log(' There is no function that could mutate it. The fee is fixed by construction.');
// ------------------------------------------------------------------
step('B', 'Programmatic attempt — invoke a non-existent updateFee()');
// ------------------------------------------------------------------
console.log(' Attempting: contract.unlock.updateFee(200)');
try {
const fn = contract.unlock?.updateFee;
if (typeof fn !== 'function') {
console.log(' REJECTED at SDK: contract.unlock.updateFee is not a function');
console.log(' ✓ Expected — the SDK only exposes functions present in the ABI');
} else {
console.log(' UNEXPECTED — updateFee resolved on contract.unlock');
try { fn(200n); console.log(' and was callable!'); }
catch (err) { console.log(` threw on invocation: ${err.message}`); }
}
} catch (err) {
console.log(` REJECTED: ${err.message}`);
}
// Also try other admin-shaped names just to be exhaustive
for (const name of ['setFee', 'changeFee', 'adminSetFee', 'migrate']) {
const fn = contract.unlock?.[name];
console.log(` contract.unlock.${name}: ${typeof fn === 'function' ? 'exists?!' : 'undefined'}`);
}
// ------------------------------------------------------------------
step('C', 'Consensus rejection — try to broadcast a claim that pays a DIFFERENT fee');
// ------------------------------------------------------------------
// Build the foundation of a valid claim, then mutate the fee output.
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
const reservoir = reservoirUtxos.find(
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === c.categoryId && u.token?.amount > 0n,
);
if (!reservoir) throw new Error('reservoir not found');
const userUtxos = await provider.getUtxos(founder.cashaddr);
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
const amount = 55n;
const grossSats = amount * rateSats; // 55,000
const contractComputedFee = (grossSats * feeBps) / 10_000n; // 550 — the contract's fee for this claim
const minerFee = 8000n;
const target = grossSats + contractComputedFee + minerFee + 1000n;
let 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(`founder BCH ${total} < ${target}`);
console.log(` claim setup: amount=${amount}, grossSats=${grossSats}, contract-computed fee=${contractComputedFee}`);
console.log('');
// Variant C1 — pay a different fee (above SDK dust, but ≠ contract-computed)
console.log(' C1) Try to pay 1000 sats fee instead of the contract\'s 550');
console.log(' (1000 clears SDK dust check so we reach broadcast; contract should still reject)');
try {
const reservoirRemaining = reservoir.token.amount - amount;
const recipientLockingBytecode = p2pkhLockingBytecode(founder.publicKeyHash);
const malformedFee = 1000n; // > 546 dust, but ≠ contract's 550 — protocol violation
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - malformedFee - minerFee;
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: c.categoryId, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: founder.tokenaddr, amount: grossSats,
token: { amount, category: c.categoryId } });
tb.addOutput({ to: c.founderAddress, amount: malformedFee }); // <-- WRONG (≠ 550)
tb.addOutput({ to: founder.cashaddr, amount: change });
await tryBroadcast(tb, 'pay 1000 sats fee', 'contract require(output[2].value == feeSats) should fire');
} catch (err) {
console.log(` SDK-level rejection (before broadcast): ${err.message?.split('\n')[0]}`);
}
console.log('');
// Variant C2 — pay a larger fee than the contract demands
console.log(' C2) Try to pay MORE fee than the contract demands (5000 sats instead of 550)');
try {
const reservoirRemaining = reservoir.token.amount - amount;
const recipientLockingBytecode = p2pkhLockingBytecode(founder.publicKeyHash);
const malformedFee = 5000n;
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - malformedFee - minerFee;
if (change < 546n) {
console.log(` skipped: change would be ${change} < dust; need more BCH inputs`);
} else {
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: c.categoryId, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: founder.tokenaddr, amount: grossSats,
token: { amount, category: c.categoryId } });
tb.addOutput({ to: c.founderAddress, amount: malformedFee }); // <-- WRONG (too high)
tb.addOutput({ to: founder.cashaddr, amount: change });
await tryBroadcast(tb, 'pay 5000 sats fee', 'same require should fire — equality, not max-bound');
}
} catch (err) {
console.log(` SDK-level rejection (before broadcast): ${err.message?.split('\n')[0]}`);
}
console.log('');
// Variant C3 — omit the fee output entirely
console.log(' C3) Try to OMIT the fee output entirely (skip output[2])');
try {
const reservoirRemaining = reservoir.token.amount - amount;
const recipientLockingBytecode = p2pkhLockingBytecode(founder.publicKeyHash);
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - minerFee; // no fee output
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: c.categoryId, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: founder.tokenaddr, amount: grossSats,
token: { amount, category: c.categoryId } });
tb.addOutput({ to: founder.cashaddr, amount: change }); // <-- output[2] is BCH-change, not the fee
await tryBroadcast(tb, 'omit fee output', 'contract checks output[2] lockingBytecode + value, both should fail');
} catch (err) {
console.log(` SDK-level rejection (before broadcast): ${err.message?.split('\n')[0]}`);
}
// ------------------------------------------------------------------
step('D', 'Verdict');
// ------------------------------------------------------------------
console.log(' A) No admin function in the ABI: founderFeeBps is a baked-in constructor arg.');
console.log(' B) The SDK refuses to even construct calls to non-existent functions.');
console.log(' C) Even if a caller bypasses the SDK and hand-builds a malformed claim,');
console.log(' BCH consensus rejects it via the contract\'s require statements:');
console.log(' require(tx.outputs[2].value == feeSats)');
console.log(' where feeSats is computed from the constructor-baked founderFeeBps.');
console.log('');
console.log(' Conclusion: the founder fee CANNOT be changed after deployment by any path.');
console.log(' The only way to "use a different fee" is to deploy an entirely new contract');
console.log(' with a new categoryId at a new P2SH32 address — i.e., a different asset.');
divider();
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});