deviant/scripts/chipnet-attack-negatives.mjs
walerikus 518a3ce174 chore: initial project baseline — full Deviant codebase under version control
Brings the whole project into git (previously only the on-ramp feature and
a few parallel files were tracked). Includes: wallet core (src/), CashScript
contracts/, the Capacitor shell (shells/), scripts/, tests, and the docs
corpus (wiki/, snapshots/, Parameters/, Archive/, KnowledgeBase text).

Excluded via .gitignore: node_modules/, dist/, .env (secrets), testnet WIF
key files (.chipnet-*.json, devWallets.ts), KnowledgeBase/RAW media (~9.6 GB),
transcripts/ (session logs), and machine-local config (.claude/*.local,
*.lock). Secret-scanned before commit — no credentials in the tree.

Note: src/swapMath.ts, src/swapBuilder.ts, contracts/OpenMintToken.cash are
legacy (OP_RETURN era) but still imported; committed as-is, flagged for a
separate removal once de-referenced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:38:07 +02:00

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