deviant/scripts/chipnet-escalating-test.mjs
walerikus 599202a721 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

319 lines
15 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-escalating-test.mjs — empirical test of the "bonding curve" /
* ever-escalating-rate variant.
*
* The rate isn't *mutated* (proven impossible) — it's *parametric on
* state*. First claim charges baseRate. Each subsequent claim charges
* more, proportional to issuedSoFar. Same contract, same bytecode, no
* admin path — just a deterministic rate function.
*
* Scenario: founder publishes BCMR metadata claiming a 1000-sat rate.
* Public claimers arriving later actually pay 6,500, then 12,000, etc.
* The founder's first-claim discount is the implicit fraud.
*
* baseRateSats = 1000
* rateIncrementPerUnit = 100
* 3 claims of 55 units each (Wallet C all three times for cost reasons):
* claim 1 (issuedSoFar=0): rate=1000, gross=55,000 fee=550
* claim 2 (issuedSoFar=55): rate=6,500, gross=357,500 fee=3,575
* claim 3 (issuedSoFar=110): rate=12,000, gross=660,000 fee=6,600
*
* Then run defences:
* D-1 — bytecode mismatch (escalating contract != canonical template)
* D-2 — per-UTXO backing check against the *published* rate (1000).
* Each UTXO is at-least-1000-per-FT → D-2 reports fully-backed
* or overbacked → D-2 DOES NOT FLAG. Demonstrates the blind spot.
*
* Cost note: C is founder and claimer for all three (founder fee
* recirculates). Net cost ≈ genesis (10k) + 3× miner (~24k) + temporarily
* locked gross (1.07M, recoverable via redeem).
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import {
Contract,
ElectrumNetworkProvider,
TransactionBuilder,
SignatureTemplate,
} from 'cashscript';
import { verifyContractCanonical } from '../src/verify/contract.ts';
import { verifyUtxoBacking } from '../src/verify/backing.ts';
const ROOT = join(import.meta.dirname, '..');
const STATE_FILE = join(ROOT, 'ChipnetTests', '.chipnet-escalating-test.json');
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT_Escalating.json');
const KEYS_C = join(ROOT, '.chipnet-keys-c.json');
const RESERVOIR_FT_SUPPLY = 1_000_000_000_000_000_000n;
const BASE_RATE_SATS = 1000n;
const RATE_INCREMENT_PER_UNIT = 100n;
const FEE_BPS = 100n; // 1%, like the canonical
const PUBLISHED_RATE_SATS = BASE_RATE_SATS; // what BCMR would claim — the lowest rate
function hexToBin(hex) {
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 binToHex(bin) {
return Array.from(bin, (b) => b.toString(16).padStart(2, '0')).join('');
}
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;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function divider(ch = '=') { console.log(ch.repeat(80)); }
function step(n, title) { console.log(''); divider(); console.log(`STEP ${n} — ${title}`); divider(); }
async function ensureEligibleUtxo(wallet) {
let utxos = await wallet.getUtxos();
let eligible = utxos.filter((u) => u.vout === 0 && !u.token);
if (eligible.length > 0) return eligible[0];
console.log(' (splitter: carving vout=0 plain-BCH UTXO)');
const ineligible = utxos.filter((u) => !(u.vout === 0 && !u.token));
const result = await wallet.send(
[{ cashaddr: wallet.cashaddr, value: 2000n, unit: 'sat' }],
ineligible.length > 0 ? { utxoIds: ineligible } : undefined,
);
console.log(` splitter TXID: ${result?.txId ?? result?.txid}`);
await sleep(12_000);
utxos = await wallet.getUtxos();
eligible = utxos.filter((u) => u.vout === 0 && !u.token);
if (eligible.length === 0) throw new Error('splitter did not produce eligible UTXO');
return eligible[0];
}
async function main() {
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const { wif } = JSON.parse(readFileSync(KEYS_C, 'utf8'));
const founder = await TestNetWallet.fromWIF(wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sigTemplate = new SignatureTemplate(founder.privateKey);
let state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : { claims: [] };
const founderLockingBytecode = p2pkhLockingBytecode(founder.publicKeyHash);
// ------------------------------------------------------------------
step(1, 'Genesis the escalating contract');
// ------------------------------------------------------------------
let entry = state.contract;
if (!entry) {
const utxo = await ensureEligibleUtxo(founder);
const categoryId = utxo.txid;
const tokenCategory = txidToConsensusBytes(categoryId);
const contract = new Contract(
artifact,
[tokenCategory, RESERVOIR_FT_SUPPLY, BASE_RATE_SATS, RATE_INCREMENT_PER_UNIT, FEE_BPS, founderLockingBytecode],
{ provider },
);
console.log(` baseRate=${BASE_RATE_SATS}, increment=${RATE_INCREMENT_PER_UNIT} per unit issued`);
console.log(` feeBps=${FEE_BPS}, initialReservoir=${RESERVOIR_FT_SUPPLY}`);
console.log(` contract: ${contract.tokenAddress}`);
console.log(` category: ${categoryId}`);
const result = await founder.tokenGenesis(
{ cashaddr: contract.tokenAddress, amount: RESERVOIR_FT_SUPPLY, value: 1000n,
nft: { capability: 'minting', commitment: '' } },
[],
{ utxoIds: [utxo] },
);
const txId = result?.txId ?? result?.txid;
console.log(` genesis TXID: ${txId}`);
entry = {
categoryId, contractAddress: contract.address, contractTokenAddress: contract.tokenAddress,
founderLockingBytecodeHex: binToHex(founderLockingBytecode),
baseRateSats: BASE_RATE_SATS.toString(),
rateIncrementPerUnit: RATE_INCREMENT_PER_UNIT.toString(),
feeBps: FEE_BPS.toString(),
initialReservoir: RESERVOIR_FT_SUPPLY.toString(),
genesisTxId: txId,
founderAddress: founder.cashaddr,
};
state.contract = entry;
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
await sleep(10_000);
} else {
console.log(` (already deployed: ${entry.contractTokenAddress})`);
}
// Reconstruct contract for claim() calls
const tokenCategory = txidToConsensusBytes(entry.categoryId);
const contract = new Contract(
artifact,
[tokenCategory, RESERVOIR_FT_SUPPLY, BASE_RATE_SATS, RATE_INCREMENT_PER_UNIT, FEE_BPS, founderLockingBytecode],
{ provider },
);
if (contract.tokenAddress !== entry.contractTokenAddress) throw new Error('contract address mismatch');
// ------------------------------------------------------------------
async function makeClaim(amount, label, expectedRate, expectedGross) {
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
const reservoir = reservoirUtxos.find(
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === entry.categoryId && u.token?.amount > 0n,
);
if (!reservoir) throw new Error('reservoir not found');
const currentReservoir = reservoir.token.amount;
const issuedSoFar = RESERVOIR_FT_SUPPLY - currentReservoir;
const actualRate = BASE_RATE_SATS + issuedSoFar * RATE_INCREMENT_PER_UNIT;
const grossSats = amount * actualRate;
const feeSats = (grossSats * FEE_BPS) / 10_000n;
console.log(` ${label}: issuedSoFar=${issuedSoFar} → rate=${actualRate} sats/unit`);
console.log(` expected: rate=${expectedRate}, gross=${expectedGross} (sanity check: ${actualRate === expectedRate && grossSats === expectedGross ? 'OK' : 'MISMATCH'})`);
console.log(` pays: gross=${grossSats}, fee=${feeSats}`);
const userUtxos = await provider.getUtxos(founder.cashaddr);
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
function pick(minerFee) {
const target = grossSats + feeSats + minerFee + 1000n;
const picked = []; let total = 0n;
for (const u of bchUtxos) { picked.push(u); total += BigInt(u.satoshis); if (total >= target) break; }
return { picked, total, target };
}
function estFee(n) {
const sz = 200n + BigInt(n + 1) * 150n + 4n * 40n;
return (sz * 3n) / 2n;
}
let { picked, total, target } = pick(5000n);
let minerFee = estFee(picked.length);
if (minerFee > 5000n) {
({ picked, total, target } = pick(minerFee));
minerFee = estFee(picked.length);
({ picked, total, target } = pick(minerFee));
}
if (total < target) throw new Error(`C BCH ${total} < ${target} (picked ${picked.length}, miner ${minerFee})`);
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - feeSats - minerFee;
if (change < 546n) throw new Error(`change ${change} < dust`);
const recipientLockingBytecode = p2pkhLockingBytecode(founder.publicKeyHash);
const remaining = currentReservoir - amount;
const tb = new TransactionBuilder({ provider });
tb.addInput(reservoir, contract.unlock.claim(amount, recipientLockingBytecode));
for (const u of picked) tb.addInput(u, sigTemplate.unlockP2PKH());
tb.addOutput({ to: contract.tokenAddress, amount: 1000n,
token: { amount: remaining, category: entry.categoryId, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: founder.tokenaddr, amount: grossSats,
token: { amount, category: entry.categoryId } });
tb.addOutput({ to: founder.cashaddr, amount: feeSats });
tb.addOutput({ to: founder.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
console.log(` claim TXID: ${txid}`);
state.claims.push({ label, amount: amount.toString(), actualRate: actualRate.toString(), grossSats: grossSats.toString(), feeSats: feeSats.toString(), txid });
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
await sleep(10_000);
return { txid, amount, actualRate, grossSats, feeSats };
}
// ------------------------------------------------------------------
step(2, 'Claim 1 — first claim at the BASE rate');
// ------------------------------------------------------------------
if (!state.claims.find((c) => c.label === 'claim1')) {
await makeClaim(55n, 'claim1', 1000n, 55_000n);
} else {
console.log(` (already done: ${state.claims.find(c => c.label === 'claim1').txid})`);
}
// ------------------------------------------------------------------
step(3, 'Claim 2 — same wallet, but the rate has ESCALATED');
// ------------------------------------------------------------------
if (!state.claims.find((c) => c.label === 'claim2')) {
// After claim1 issued 55 → expected rate = 1000 + 55*100 = 6500
await makeClaim(55n, 'claim2', 6500n, 357_500n);
} else {
console.log(` (already done: ${state.claims.find(c => c.label === 'claim2').txid})`);
}
// ------------------------------------------------------------------
step(4, 'Claim 3 — rate keeps climbing');
// ------------------------------------------------------------------
if (!state.claims.find((c) => c.label === 'claim3')) {
// After claim1+2 issued 110 → expected rate = 1000 + 110*100 = 12000
await makeClaim(55n, 'claim3', 12_000n, 660_000n);
} else {
console.log(` (already done: ${state.claims.find(c => c.label === 'claim3').txid})`);
}
// ------------------------------------------------------------------
step(5, 'D-1 — does the wallet flag the escalating contract as non-canonical?');
// ------------------------------------------------------------------
const d1 = verifyContractCanonical({
categoryId: entry.categoryId,
backingRateSats: PUBLISHED_RATE_SATS,
feeBps: FEE_BPS,
founderLockingBytecode,
onChainContractTokenAddress: entry.contractTokenAddress,
});
console.log(` D-1 verdict: ${d1.status}`);
console.log(` canonical-template address (published rate=${PUBLISHED_RATE_SATS}): ${d1.canonicalAddress}`);
console.log(` on-chain escalating contract address: ${d1.onChainAddress}`);
console.log(` equal? ${d1.canonicalAddress === d1.onChainAddress}`);
if (d1.status === 'non-canonical') {
console.log(' ✓ D-1 caught the escalating contract — peg flagged "not guaranteed"');
} else {
console.log(' UNEXPECTED — D-1 should have flagged non-canonical');
}
// ------------------------------------------------------------------
step(6, 'D-2 — per-UTXO backing vs PUBLISHED rate (the blind spot)');
// ------------------------------------------------------------------
console.log(` Wallet C now holds 3 FT UTXOs of category ${entry.categoryId.slice(0, 16)}...`);
console.log(` D-2 compares each UTXO\'s sats against ft_amount × publishedRate (${PUBLISHED_RATE_SATS}).`);
console.log('');
const utxos = await provider.getUtxos(founder.cashaddr);
const ftUtxos = utxos.filter(
(u) => u.token?.category === entry.categoryId && !u.token?.nft && u.token?.amount > 0n,
).sort((a, b) => Number(b.satoshis - a.satoshis));
for (const u of ftUtxos) {
const verdict = verifyUtxoBacking({
utxoSatoshis: BigInt(u.satoshis),
utxoTokenAmount: u.token.amount,
backingRateSats: PUBLISHED_RATE_SATS,
});
const overbackingRatio = u.token.amount > 0n
? Number((BigInt(u.satoshis) * 1000n) / (u.token.amount * PUBLISHED_RATE_SATS)) / 1000
: 0;
console.log(` UTXO ${u.txid.slice(0, 16)}:${u.vout} — ${u.token.amount} FT, ${u.satoshis} sats`);
console.log(` peg-at-published requires ${verdict.expectedSats} sats → ${verdict.status.toUpperCase()}`);
console.log(` actual backing ratio: ${overbackingRatio.toFixed(2)}× the published rate`);
}
// ------------------------------------------------------------------
step(7, 'Verdict');
// ------------------------------------------------------------------
console.log(' A founder CAN write a contract whose rate is a deterministic function of state.');
console.log(' Three independent claims through the same contract paid three different rates:');
for (const c of state.claims) {
console.log(` ${c.label}: amount=${c.amount}, actualRate=${c.actualRate}, grossSats=${c.grossSats}`);
}
console.log('');
console.log(' Defences:');
console.log(' D-1 fires: escalating contract has different bytecode from canonical →');
console.log(' different P2SH32 address → "peg not guaranteed." ← load-bearing');
console.log(' D-2 has a structural gap for this scheme: each UTXO IS at-least-backed-at-the-');
console.log(' published-rate (every per-claim rate ≥ baseRate = publishedRate),');
console.log(' so D-2 reports fully-backed or overbacked, not underbacked.');
console.log('');
console.log(' Conclusion: protocol allows arbitrary deterministic rate functions. D-1 is the');
console.log(' only general-purpose defence against this class. The discipline of pinning the');
console.log(' canonical bytecode (ADR-0008) is what keeps "Deviant tokens" actually Deviant.');
divider();
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});