#!/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, '.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); });