deviant/scripts/chipnet-attack-negatives.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

216 lines
8.2 KiB
JavaScript
Raw Permalink 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-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);
});