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>
216 lines
8.2 KiB
JavaScript
216 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* chipnet-attack-negatives.mjs — attempts to break the contract that
|
||
* are EXPECTED to fail. If any of these succeed, that's a bug.
|
||
*
|
||
* Tests:
|
||
* N1. Reservoir lockingBytecode swap: redirect the reservoir output to
|
||
* a non-contract address. Should fail at the contract's
|
||
* `require(tx.outputs[0].lockingBytecode == input.lockingBytecode)`.
|
||
* N2. FT overflow: pick a tokenAmount large enough that
|
||
* tokenAmount * rate overflows 64-bit signed. Should fail at the
|
||
* Script multiplication opcode.
|
||
* N3. Negative tokenAmount: claim a negative number of tokens. Should
|
||
* fail at `require(tokenAmount > 0)`.
|
||
*
|
||
* Each test builds the tx but does NOT broadcast (uses .build() not
|
||
* .send()). Validation errors are reported as expected results.
|
||
*/
|
||
import { 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 KEYS_B = join(ROOT, '.chipnet-keys-b.json');
|
||
const TOKENS_FILE = join(ROOT, 'ChipnetTests', '.chipnet-tokens.json');
|
||
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
|
||
|
||
function hexToBin(hex) {
|
||
if (hex.length % 2 !== 0) throw new Error(`odd hex length: ${hex.length}`);
|
||
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() { console.log('-'.repeat(72)); }
|
||
|
||
async function runTest(name, expectedFailurePatterns, buildFn) {
|
||
console.log('');
|
||
console.log(`[${name}] expected failure matching: ${expectedFailurePatterns.join(' OR ')}`);
|
||
divider();
|
||
try {
|
||
await buildFn();
|
||
console.log(` RESULT: DEBUG SUCCEEDED. UNEXPECTED — this attack should fail!`);
|
||
return { name, status: 'unexpected-success' };
|
||
} catch (err) {
|
||
const msg = err?.message ?? String(err);
|
||
const expected = expectedFailurePatterns.some((p) => msg.toLowerCase().includes(p.toLowerCase()));
|
||
const status = expected ? 'expected-failure' : 'unexpected-failure';
|
||
console.log(` RESULT: ${status}`);
|
||
console.log(` Error excerpt: ${msg.split('\n')[0].slice(0, 220)}`);
|
||
return { name, status, error: msg };
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const { wif } = JSON.parse(readFileSync(KEYS_B, 'utf8'));
|
||
const tokens = JSON.parse(readFileSync(TOKENS_FILE, 'utf8'));
|
||
const tokenA = tokens.tokens.find((t) => t.name === 'TokenA');
|
||
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
|
||
|
||
const attacker = await TestNetWallet.fromWIF(wif);
|
||
const provider = new ElectrumNetworkProvider('chipnet');
|
||
const sigTemplate = new SignatureTemplate(attacker.privateKey);
|
||
|
||
const tokenCategory = txidToConsensusBytes(tokenA.categoryId);
|
||
const founderLockingBytecode = hexToBin(tokenA.founderLockingBytecodeHex);
|
||
const contract = new Contract(
|
||
artifact,
|
||
[tokenCategory, BigInt(tokenA.rateSats), BigInt(tokenA.founderFeeBps), founderLockingBytecode],
|
||
{ provider },
|
||
);
|
||
|
||
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
|
||
const reservoir = reservoirUtxos.find(
|
||
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === tokenA.categoryId,
|
||
);
|
||
if (!reservoir) throw new Error('Reservoir not found');
|
||
|
||
const attackerUtxos = await provider.getUtxos(attacker.cashaddr);
|
||
const bchUtxos = attackerUtxos.filter((u) => !u.token);
|
||
if (bchUtxos.length === 0) throw new Error('Attacker has no BCH UTXO');
|
||
const feeInput = bchUtxos.reduce((a, b) => BigInt(a.satoshis) > BigInt(b.satoshis) ? a : b);
|
||
|
||
const rateSats = BigInt(tokenA.rateSats);
|
||
const attackerLB = p2pkhLockingBytecode(attacker.publicKeyHash);
|
||
|
||
console.log('='.repeat(72));
|
||
console.log('CONTRACT NEGATIVE TESTS — should ALL fail at local validation');
|
||
console.log('='.repeat(72));
|
||
console.log(` Reservoir: ${reservoir.token.amount} FTs, ${reservoir.satoshis} sats`);
|
||
console.log(` Attacker BCH: ${feeInput.satoshis} sats`);
|
||
|
||
const results = [];
|
||
|
||
// N1: redirect reservoir output to attacker's address.
|
||
results.push(await runTest(
|
||
'N1: reservoir lockingBytecode swap',
|
||
['lockingBytecode', 'Require statement failed', 'line 64', 'line 65'],
|
||
async () => {
|
||
const tb = new TransactionBuilder({ provider });
|
||
tb.addInput(reservoir, contract.unlock.claim(1n, attackerLB));
|
||
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
|
||
// EVIL: reservoir-back output going to attacker, not contract
|
||
tb.addOutput({
|
||
to: attacker.tokenaddr,
|
||
amount: 1000n,
|
||
token: {
|
||
amount: reservoir.token.amount - 1n,
|
||
category: tokenA.categoryId,
|
||
nft: { capability: 'minting', commitment: '' },
|
||
},
|
||
});
|
||
tb.addOutput({ to: attacker.tokenaddr, amount: rateSats, token: { amount: 1n, category: tokenA.categoryId } });
|
||
tb.addOutput({ to: attacker.cashaddr, amount: BigInt(feeInput.satoshis) - 4000n });
|
||
tb.debug();
|
||
},
|
||
));
|
||
|
||
// N2: FT overflow — pick tokenAmount × rate > 2^63 - 1.
|
||
// rate=1000, so tokenAmount > 9.22e15 overflows. Pick 10^17.
|
||
results.push(await runTest(
|
||
'N2: FT overflow on grossSats',
|
||
['overflow', 'out of range', 'too large', 'Number overflow', 'arithmetic'],
|
||
async () => {
|
||
const overflowAmount = 10n ** 17n;
|
||
const tb = new TransactionBuilder({ provider });
|
||
tb.addInput(reservoir, contract.unlock.claim(overflowAmount, attackerLB));
|
||
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
|
||
tb.addOutput({
|
||
to: contract.tokenAddress,
|
||
amount: 1000n,
|
||
token: {
|
||
amount: reservoir.token.amount - overflowAmount,
|
||
category: tokenA.categoryId,
|
||
nft: { capability: 'minting', commitment: '' },
|
||
},
|
||
});
|
||
// tokenAmount * rate would overflow. We don't even need a valid
|
||
// grossSats here — the contract will throw at the multiplication.
|
||
tb.addOutput({
|
||
to: attacker.tokenaddr,
|
||
amount: 1000n,
|
||
token: { amount: overflowAmount, category: tokenA.categoryId },
|
||
});
|
||
tb.addOutput({ to: attacker.cashaddr, amount: BigInt(feeInput.satoshis) - 4000n });
|
||
tb.debug();
|
||
},
|
||
));
|
||
|
||
// N3: negative tokenAmount. The SDK refuses to construct an output with
|
||
// negative `tokens` at addOutput() time, so we don't even reach the
|
||
// contract's own `require(tokenAmount > 0)` line. Either failure mode
|
||
// counts as guarded.
|
||
results.push(await runTest(
|
||
'N3: negative tokenAmount',
|
||
['invalid', 'tokenAmount', 'tokens', 'Require statement failed'],
|
||
async () => {
|
||
const tb = new TransactionBuilder({ provider });
|
||
tb.addInput(reservoir, contract.unlock.claim(-1n, attackerLB));
|
||
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
|
||
tb.addOutput({
|
||
to: contract.tokenAddress,
|
||
amount: 1000n,
|
||
token: {
|
||
amount: reservoir.token.amount + 1n,
|
||
category: tokenA.categoryId,
|
||
nft: { capability: 'minting', commitment: '' },
|
||
},
|
||
});
|
||
tb.addOutput({
|
||
to: attacker.tokenaddr,
|
||
amount: 1000n,
|
||
token: { amount: -1n, category: tokenA.categoryId },
|
||
});
|
||
tb.addOutput({ to: attacker.cashaddr, amount: BigInt(feeInput.satoshis) - 4000n });
|
||
tb.debug();
|
||
},
|
||
));
|
||
|
||
console.log('');
|
||
console.log('='.repeat(72));
|
||
console.log('SUMMARY');
|
||
console.log('='.repeat(72));
|
||
for (const r of results) {
|
||
console.log(` ${r.name}: ${r.status}`);
|
||
}
|
||
const allExpected = results.every((r) => r.status === 'expected-failure');
|
||
console.log('');
|
||
console.log(allExpected
|
||
? 'ALL NEGATIVE TESTS FAILED AS EXPECTED — contract guards work.'
|
||
: 'WARNING: some tests did not fail as expected. Review individually.');
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('SCRIPT FAILED:', err?.message ?? err);
|
||
if (err?.stack) console.error(err.stack);
|
||
process.exit(1);
|
||
});
|