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

240 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-swap.mjs — step 6 of the Q4 validation plan (snapshot 0005).
* THE Q4 TEST.
*
* Builds and broadcasts a single chipnet transaction that simultaneously:
* - Burns the wallet's 100 Token A FTs, releasing their 100,000 sats
* of BCH backing (Token A rate = 1000 sats/unit).
* - Drives Token B's contract.mint() to transfer 10 Token B FTs from
* Token B's reservoir to the wallet, backed by those 100,000 sats
* (Token B rate = 10,000 sats/unit).
*
* Both halves are inputs in the SAME transaction. BCH transaction
* atomicity guarantees they succeed together or neither does — no
* inter-contract coupling, no counterparty, no relay, no DEX. Just the
* native BCH transaction model. This is what the spec calls the
* "novel claim": counterparty-free protocol swap between open-mint
* CashTokens via fixed BCH backing rates.
*
* Transaction shape:
*
* Inputs:
* [0] Token B reservoir UTXO (at Token B's contract token-address)
* - 1000 sats + 10^18 Token B FTs + minting NFT
* - Unlocked via contract.unlock.mint(10n, walletLockingBytecode)
* [1] Token A FT UTXO (at wallet)
* - 100,000 sats + 100 Token A FTs
* - Unlocked via P2PKH sig
* [2] BCH UTXO (at wallet) — covers miner fee
* - Unlocked via P2PKH sig
*
* Outputs:
* [0] Token B reservoir back to contract
* - 1000 sats + (10^18 - 10) Token B FTs + minting NFT
* [1] Token B FT to wallet (self-backed)
* - 100,000 sats + 10 Token B FTs
* [2] BCH change back to wallet
*
* Token conservation:
* - Token A: 100 in input, 0 in output → BURN by 100 (allowed; cashscript
* SDK requires allowImplicitFungibleTokenBurn=true).
* - Token B: 10^18 in input, 10^18 in output (reservoir down 10, FT
* UTXO up 10). Conserved.
*
* No founder fee (both tokens were genesised with feeBps=0).
*/
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_FILE = join(ROOT, '.chipnet-keys.json');
const TOKENS_FILE = join(ROOT, 'ChipnetTests', '.chipnet-tokens.json');
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const SWAP_SOURCE_UNITS = 100n; // burn 100 Token A
const SWAP_TARGET_UNITS = 10n; // mint 10 Token B
// Math sanity (mirrors the on-chain swap arithmetic):
// sourceUnits * sourceRate = 100 * 1000 = 100,000 sats released
// targetUnits * targetRate = 10 * 10,000 = 100,000 sats consumed
// exact integer ratio, no sub-rate dust at the target UTXO.
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_FILE, 'utf8'));
const tokensFile = JSON.parse(readFileSync(TOKENS_FILE, 'utf8'));
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const tokenA = tokensFile.tokens.find((t) => t.name === 'TokenA');
const tokenB = tokensFile.tokens.find((t) => t.name === 'TokenB');
if (!tokenA || !tokenB) throw new Error('TokenA or TokenB missing in .chipnet-tokens.json');
const wallet = await TestNetWallet.fromWIF(wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sigTemplate = new SignatureTemplate(wallet.privateKey);
const walletLockingBytecode = p2pkhLockingBytecode(wallet.publicKeyHash);
// Reconstruct Token B's contract.
const tokenB_Category = txidToConsensusBytes(tokenB.categoryId);
const tokenB_FounderLB = hexToBin(tokenB.founderLockingBytecodeHex);
const contractB = new Contract(
artifact,
[tokenB_Category, BigInt(tokenB.rateSats), BigInt(tokenB.founderFeeBps), tokenB_FounderLB],
{ provider },
);
if (contractB.tokenAddress !== tokenB.contractTokenAddress) {
throw new Error(
`Token B contract address mismatch: expected ${tokenB.contractTokenAddress}, got ${contractB.tokenAddress}`,
);
}
// Fetch the Token B reservoir UTXO (the minting NFT + FT supply).
const contractBUtxos = await provider.getUtxos(contractB.tokenAddress);
const reservoirB = contractBUtxos.find(
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === tokenB.categoryId,
);
if (!reservoirB) throw new Error('Token B reservoir UTXO not found at contract');
// Fetch the wallet's Token A FT UTXO (the one we minted in step 5).
const walletAllUtxos = await provider.getUtxos(wallet.cashaddr);
const tokenAFtUtxos = walletAllUtxos.filter(
(u) => u.token?.category === tokenA.categoryId && !u.token?.nft && u.token?.amount > 0n,
);
const tokenAFt = tokenAFtUtxos.find((u) => u.token.amount >= SWAP_SOURCE_UNITS);
if (!tokenAFt) {
throw new Error(
`No Token A FT UTXO with at least ${SWAP_SOURCE_UNITS} units. Run chipnet:mint-a first.`,
);
}
// Pick a BCH UTXO (non-token) for miner-fee top-up.
const bchUtxos = walletAllUtxos.filter((u) => !u.token);
if (bchUtxos.length === 0) throw new Error('No BCH UTXO available to cover miner fee');
const feeInput = bchUtxos[0];
// Math.
const sourceRate = BigInt(tokenA.rateSats); // 1000
const targetRate = BigInt(tokenB.rateSats); // 10000
const releasedSats = SWAP_SOURCE_UNITS * sourceRate; // 100,000
const targetFtSats = SWAP_TARGET_UNITS * targetRate; // 100,000
if (releasedSats !== targetFtSats) {
// For non-divisible rates the swap math would leave sub-rate sats inside
// the target UTXO. For this test we deliberately picked a clean ratio.
throw new Error(
`Expected exact swap (released ${releasedSats} == target sats ${targetFtSats}); refusing inexact.`,
);
}
const reservoirRemaining = reservoirB.token.amount - SWAP_TARGET_UNITS;
divider();
console.log('Q4 swap: 100 Token A → 10 Token B (atomic)');
divider();
console.log(` Token A FT input: ${tokenAFt.txid.slice(0, 16)}:${tokenAFt.vout} (${tokenAFt.satoshis} sat, ${tokenAFt.token.amount} FTs)`);
console.log(` Token B reservoir: ${reservoirB.txid.slice(0, 16)}:${reservoirB.vout} (${reservoirB.satoshis} sat, ${reservoirB.token.amount} FTs)`);
console.log(` BCH fee input: ${feeInput.txid.slice(0, 16)}:${feeInput.vout} (${feeInput.satoshis} sat)`);
console.log(` Released by burn: ${releasedSats} sats`);
console.log(` Target FT backing: ${targetFtSats} sats backing ${SWAP_TARGET_UNITS} Token B units`);
console.log(` Reservoir remaining: ${reservoirRemaining} Token B FTs`);
// Compute the miner fee + change. This tx has 1 covenant input + 2
// P2PKH inputs + 3 outputs (no founder-fee output). Conservative budget:
const minerFee = 3000n;
const inputTotal = BigInt(reservoirB.satoshis) + BigInt(tokenAFt.satoshis) + BigInt(feeInput.satoshis);
const outputBcheReserve = 1000n; // reservoir back-out
const change = inputTotal - outputBcheReserve - targetFtSats - minerFee;
if (change < 546n) {
throw new Error(
`Change ${change} below dust. inputs=${inputTotal} reservoir=${outputBcheReserve} ft=${targetFtSats} fee=${minerFee}`,
);
}
console.log(` Miner fee: ${minerFee} sats`);
console.log(` Change: ${change} sats back to wallet`);
divider();
// Build the transaction. The swap burns Token A FTs (input has 100,
// outputs have 0) — cashscript SDK refuses implicit FT burns by
// default, so we opt-in via the constructor option.
const tb = new TransactionBuilder({ provider, allowImplicitFungibleTokenBurn: true });
// Input [0]: Token B reservoir. Active input for the covenant.
tb.addInput(
reservoirB,
contractB.unlock.claim(SWAP_TARGET_UNITS, walletLockingBytecode),
);
// Input [1]: Token A FT UTXO (P2PKH-locked, signed by wallet).
tb.addInput(tokenAFt, sigTemplate.unlockP2PKH());
// Input [2]: BCH UTXO for fee.
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
// Output [0]: Token B reservoir back to contract.
tb.addOutput({
to: contractB.tokenAddress,
amount: outputBcheReserve,
token: {
amount: reservoirRemaining,
category: tokenB.categoryId,
nft: { capability: 'minting', commitment: '' },
},
});
// Output [1]: Token B FT to wallet, self-backed.
tb.addOutput({
to: wallet.tokenaddr,
amount: targetFtSats,
token: {
amount: SWAP_TARGET_UNITS,
category: tokenB.categoryId,
},
});
// Output [2]: BCH 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('');
console.log(`*** Q4 SWAP BROADCAST: ${txid} ***`);
divider();
console.log('If this transaction confirms, Q4 is answered YES on chipnet.');
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});