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