deviant/scripts/chipnet-bonding-swap-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

298 lines
16 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-bonding-swap-test.mjs — does the protocol swap still work when
* the TARGET is a bonding-curve (escalating-rate) token?
*
* The user's hypothesis: yes — the swap can compute each side's CURRENT
* rate from on-chain state at execution time and process accordingly.
* The exchange rate becomes a dynamic quote (like an AMM) rather than a
* fixed constant, but atomicity and counterparty-freedom are preserved.
*
* Test setup (all wallet C):
* 1. Deploy SWAPSRC — canonical fixed-rate token (rate=1000, fee=0).
* 2. C claims two separate 120-unit SWAPSRC UTXOs (120,000 sats each).
* 3. Deploy BCT — escalating target (baseRate=10,000, increment=500, fee=0).
* 4. SWAP 1: spend SWAPSRC UTXO #1 (releases 120,000 sats), atomically
* mint BCT at the current rate. issuedSoFar=0 → rate=10,000 →
* mint floor(120,000 / 10,000) = 12 BCT.
* 5. SWAP 2: spend SWAPSRC UTXO #2 (releases 120,000 sats), atomically
* mint BCT at the NEW current rate. issuedSoFar=12 →
* rate=10,000 + 12×500 = 16,000 → mint floor(120,000 / 16,000) = 7 BCT.
*
* Same input BCH both times (120,000 sats); different output (12 vs 7 BCT)
* because the bonding-curve rate escalated between swaps. Each swap is a
* single atomic BCH transaction — source FTs burned, target FTs minted —
* with no counterparty.
*
* This proves the swap mechanism is rate-function-agnostic: the source side
* is a P2PKH spend that releases whatever BCH the UTXO carries; the target
* side mints at whatever rate ITS contract computes from current state.
*/
import { existsSync, readFileSync, writeFileSync } 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 STATE_FILE = join(ROOT, 'ChipnetTests', '.chipnet-bonding-swap-test.json');
const CANONICAL_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const ESCALATING_ARTIFACT = 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;
// Source: fixed-rate canonical
const SRC_RATE = 1000n;
const SRC_FEE_BPS = 0n;
// Target: bonding curve
const BCT_BASE_RATE = 10_000n;
const BCT_INCREMENT = 500n;
const BCT_FEE_BPS = 0n;
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)');
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 failed');
return eligible[0];
}
function pickBch(bchUtxos, need) {
const picked = []; let total = 0n;
for (const u of bchUtxos) { picked.push(u); total += BigInt(u.satoshis); if (total >= need) break; }
return { picked, total };
}
async function main() {
const canonicalArtifact = JSON.parse(readFileSync(CANONICAL_ARTIFACT, 'utf8'));
const escalatingArtifact = JSON.parse(readFileSync(ESCALATING_ARTIFACT, 'utf8'));
const { wif } = JSON.parse(readFileSync(KEYS_C, 'utf8'));
const wallet = await TestNetWallet.fromWIF(wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sig = new SignatureTemplate(wallet.privateKey);
const founderLockingBytecode = p2pkhLockingBytecode(wallet.publicKeyHash);
const recipientLockingBytecode = p2pkhLockingBytecode(wallet.publicKeyHash);
let state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : { swaps: [] };
// ================================================================
step(1, 'Deploy SWAPSRC (canonical fixed-rate, rate=1000, fee=0)');
// ================================================================
let src = state.src;
if (!src) {
const utxo = await ensureEligibleUtxo(wallet);
const categoryId = utxo.txid;
const contract = new Contract(canonicalArtifact,
[txidToConsensusBytes(categoryId), SRC_RATE, SRC_FEE_BPS, founderLockingBytecode], { provider });
console.log(` contract: ${contract.tokenAddress}`);
console.log(` category: ${categoryId}`);
const result = await wallet.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}`);
src = { categoryId, contractTokenAddress: contract.tokenAddress, genesisTxId: txId };
state.src = src;
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
await sleep(10_000);
} else { console.log(` (already deployed: ${src.contractTokenAddress})`); }
const srcContract = new Contract(canonicalArtifact,
[txidToConsensusBytes(src.categoryId), SRC_RATE, SRC_FEE_BPS, founderLockingBytecode], { provider });
// ================================================================
step(2, 'C claims two 120-unit SWAPSRC UTXOs (120,000 sats backing each)');
// ================================================================
async function claimSrc(label) {
const amount = 120n;
const grossSats = amount * SRC_RATE; // 120,000
const reservoirUtxos = await provider.getUtxos(srcContract.tokenAddress);
const reservoir = reservoirUtxos.find((u) => u.token?.nft?.capability === 'minting' && u.token?.category === src.categoryId && u.token?.amount > 0n);
if (!reservoir) throw new Error('SWAPSRC reservoir not found');
const minerFee = 6000n;
const need = grossSats + minerFee + 1000n;
const userUtxos = await provider.getUtxos(wallet.cashaddr);
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
const { picked, total } = pickBch(bchUtxos, need);
if (total < need) throw new Error(`need ${need}, have ${total}`);
const change = BigInt(reservoir.satoshis) + total - 1000n - grossSats - minerFee;
const tb = new TransactionBuilder({ provider });
tb.addInput(reservoir, srcContract.unlock.claim(amount, recipientLockingBytecode));
for (const u of picked) tb.addInput(u, sig.unlockP2PKH());
tb.addOutput({ to: srcContract.tokenAddress, amount: 1000n,
token: { amount: reservoir.token.amount - amount, category: src.categoryId, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: wallet.tokenaddr, amount: grossSats, token: { amount, category: src.categoryId } });
tb.addOutput({ to: wallet.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
console.log(` ${label}: claimed ${amount} SWAPSRC (120,000 sats) — TXID ${txid}`);
await sleep(10_000);
return txid;
}
state.srcClaims = state.srcClaims ?? [];
while (state.srcClaims.length < 2) {
const txid = await claimSrc(`SWAPSRC claim ${state.srcClaims.length + 1}`);
state.srcClaims.push(txid);
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
if (state.srcClaims.length >= 2) console.log(` (have ${state.srcClaims.length} SWAPSRC UTXOs)`);
// ================================================================
step(3, 'Deploy BCT (escalating target, baseRate=10000, increment=500, fee=0)');
// ================================================================
let bct = state.bct;
if (!bct) {
const utxo = await ensureEligibleUtxo(wallet);
const categoryId = utxo.txid;
const contract = new Contract(escalatingArtifact,
[txidToConsensusBytes(categoryId), RESERVOIR_FT_SUPPLY, BCT_BASE_RATE, BCT_INCREMENT, BCT_FEE_BPS, founderLockingBytecode], { provider });
console.log(` contract: ${contract.tokenAddress}`);
console.log(` category: ${categoryId}`);
const result = await wallet.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}`);
bct = { categoryId, contractTokenAddress: contract.tokenAddress, genesisTxId: txId };
state.bct = bct;
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
await sleep(10_000);
} else { console.log(` (already deployed: ${bct.contractTokenAddress})`); }
const bctContract = new Contract(escalatingArtifact,
[txidToConsensusBytes(bct.categoryId), RESERVOIR_FT_SUPPLY, BCT_BASE_RATE, BCT_INCREMENT, BCT_FEE_BPS, founderLockingBytecode], { provider });
// ================================================================
async function doSwap(label) {
// Find a SWAPSRC FT UTXO to swap from
const cUtxos = await provider.getUtxos(wallet.cashaddr);
const srcFt = cUtxos.find((u) => u.token?.category === src.categoryId && !u.token?.nft && u.token?.amount === 120n);
if (!srcFt) throw new Error('no 120-unit SWAPSRC UTXO available to swap');
const releasedSats = BigInt(srcFt.satoshis); // 120,000
// Read BCT current state → compute current rate
const bctReservoirUtxos = await provider.getUtxos(bctContract.tokenAddress);
const reservoir = bctReservoirUtxos.find((u) => u.token?.nft?.capability === 'minting' && u.token?.category === bct.categoryId && u.token?.amount > 0n);
if (!reservoir) throw new Error('BCT reservoir not found');
const issuedSoFar = RESERVOIR_FT_SUPPLY - reservoir.token.amount;
const currentRate = BCT_BASE_RATE + issuedSoFar * BCT_INCREMENT;
// How many BCT can the released BCH mint at the current rate?
const targetUnits = releasedSats / currentRate; // integer floor
const targetValue = targetUnits * currentRate; // exact value output[1] must carry
const leftover = releasedSats - targetValue; // returns to swapper as change
console.log(` ${label}:`);
console.log(` BCT issuedSoFar=${issuedSoFar} → currentRate=${currentRate} sats/BCT`);
console.log(` source releases ${releasedSats} sats → mints ${targetUnits} BCT (value ${targetValue}), leftover ${leftover}`);
// Miner fee from an extra BCH input
const minerFee = 6000n;
const bchOnly = cUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
const { picked, total } = pickBch(bchOnly, minerFee + 1000n);
if (total < minerFee + 1000n) throw new Error('insufficient BCH for miner fee');
// change = leftover (from source) + extra BCH inputs + reservoir-in - reservoir-out - targetValue - minerFee
const change = leftover + total + BigInt(reservoir.satoshis) - 1000n - minerFee;
const tb = new TransactionBuilder({ provider, allowImplicitFungibleTokenBurn: true });
// Input 0: source FT UTXO (P2PKH spend, FTs burned)
tb.addInput(srcFt, sig.unlockP2PKH());
// Input 1: BCT reservoir (covenant) — mint targetUnits
tb.addInput(reservoir, bctContract.unlock.claim(targetUnits, recipientLockingBytecode));
// Inputs 2+: BCH for miner fee
for (const u of picked) tb.addInput(u, sig.unlockP2PKH());
// Output 0: BCT reservoir back
tb.addOutput({ to: bctContract.tokenAddress, amount: 1000n,
token: { amount: reservoir.token.amount - targetUnits, category: bct.categoryId, nft: { capability: 'minting', commitment: '' } } });
// Output 1: BCT FTs to swapper at current rate
tb.addOutput({ to: wallet.tokenaddr, amount: targetValue, token: { amount: targetUnits, category: bct.categoryId } });
// Output 2: change
tb.addOutput({ to: wallet.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
console.log(` SWAP TXID: ${txid} (120 SWAPSRC burned → ${targetUnits} BCT minted, atomic)`);
await sleep(10_000);
return { label, issuedSoFar: issuedSoFar.toString(), currentRate: currentRate.toString(), targetUnits: targetUnits.toString(), txid };
}
// ================================================================
step(4, 'SWAP 1 — fixed-rate SWAPSRC → bonding-curve BCT at base rate');
// ================================================================
if (!state.swaps.find((s) => s.label === 'swap1')) {
const r = await doSwap('swap1');
state.swaps.push(r);
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} else { console.log(` (already done: ${state.swaps.find(s => s.label === 'swap1').txid})`); }
// ================================================================
step(5, 'SWAP 2 — same 120,000 sats input, but BCT rate has ESCALATED');
// ================================================================
if (!state.swaps.find((s) => s.label === 'swap2')) {
const r = await doSwap('swap2');
state.swaps.push(r);
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} else { console.log(` (already done: ${state.swaps.find(s => s.label === 'swap2').txid})`); }
// ================================================================
step(6, 'Result');
// ================================================================
console.log(' | swap | BCT issuedSoFar | currentRate | BCT minted | source BCH |');
console.log(' |-------|-----------------|-------------|------------|------------|');
for (const s of state.swaps) {
console.log(` | ${s.label} | ${s.issuedSoFar.padStart(15)} | ${s.currentRate.padStart(11)} | ${s.targetUnits.padStart(10)} | 120,000 |`);
}
console.log('');
console.log(' Both swaps spent the SAME 120,000 sats of source backing. The number of');
console.log(' BCT minted DIFFERED because the bonding-curve rate escalated between swaps.');
console.log(' Each swap was a single atomic BCH transaction: source SWAPSRC FTs burned,');
console.log(' target BCT FTs minted at the CURRENT (state-derived) rate, no counterparty.');
console.log('');
console.log(' CONFIRMED: the protocol swap is rate-function-agnostic. The source side is a');
console.log(' P2PKH spend releasing the UTXO\'s BCH; the target side mints at whatever rate');
console.log(' its contract computes from current state. A fixed rate is just the special');
console.log(' case where that computation returns a constant. Dynamic-rate swaps work — the');
console.log(' exchange rate is a quote at execution time (front-runnable, like AMM slippage),');
console.log(' not a constant known in advance.');
divider();
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});