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>
247 lines
13 KiB
JavaScript
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);
|
|
});
|