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>
319 lines
15 KiB
JavaScript
319 lines
15 KiB
JavaScript
#!/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);
|
||
});
|