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>
240 lines
9.5 KiB
JavaScript
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);
|
|
});
|