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

253 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-genesis.mjs — step 4 of the Q4 validation plan (snapshot 0005).
*
* Creates two open-mint CashTokens categories on chipnet — Token A
* (rate 1000 sats/unit) and Token B (rate 10000 sats/unit) — and deploys
* each one's minting NFT directly to the DeviantOpenMintFT contract
* address for that category. After this script runs successfully, both
* tokens have a publicly-callable mint() endpoint at a known address and
* .chipnet-tokens.json records the metadata the next two scripts need.
*
* CashTokens Category-ID precision (correction to spec §1.2):
*
* Per CHIP-2022-02-CashTokens, the category_id of a new category is the
* TXID of the *prev_out at input[0]* of the genesis transaction — NOT
* the TXID of the genesis transaction itself. The genesis transaction
* is REQUIRED to spend a UTXO at prevout.vout == 0. So:
*
* - Find a wallet UTXO with vout == 0 and no attached token.
* - That UTXO's txid is the Category ID — known BEFORE broadcasting.
* - Build the contract address from that Category ID.
* - Spend that UTXO in a genesis tx whose first output is the minting
* NFT going directly to the contract token-address.
*
* No chicken-and-egg. One transaction per token. The spec's phrasing
* ("TXID of the genesis transaction becomes the Category ID") is an
* off-by-one in the CHIP's directionality — the prevout's TXID is what
* becomes the Category ID. Recorded here so we don't relitigate it.
*
* Idempotency:
*
* If .chipnet-tokens.json already exists, the script reports the
* existing tokens and exits without sending any transactions. Delete
* that file to re-run (and you'll get fresh Category IDs).
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider } 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');
// Reservoir model: genesis allocates the entire FT supply to a single
// UTXO at the contract address. mint() transfers from the reservoir. We
// choose a supply well below the consensus cap (2^63-1) but high enough
// to be effectively unlimited for any plausible adoption curve.
const RESERVOIR_FT_SUPPLY = 1_000_000_000_000_000_000n; // 10^18
const TOKENS = [
{ name: 'TokenA', rateSats: 1_000n, founderFeeBps: 0n },
{ name: 'TokenB', rateSats: 10_000n, founderFeeBps: 0n },
];
function hexToBin(hex) {
if (typeof hex !== 'string') throw new Error(`hexToBin expects string, got ${typeof 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;
}
// TXIDs are conventionally displayed big-endian (RPC order) but the BCH
// consensus serialization (and the bytes returned by introspection opcodes
// like OP_OUTPUTTOKENCATEGORY) is little-endian. The CashScript SDK
// automatically byte-reverses string categories passed to addOutput so
// that the on-chain field matches consensus — but it does NOT reverse
// constructor `bytes` arguments. So for the contract's
// `require(tx.outputs[i].tokenCategory == tokenCategory_param)` check
// to pass, the constructor must receive the consensus-order (reversed)
// bytes. This helper does that reversal.
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) {
if (pkh.length !== 20) throw new Error(`pkh must be 20 bytes, got ${pkh.length}`);
const out = new Uint8Array(25);
out[0] = 0x76; // OP_DUP
out[1] = 0xa9; // OP_HASH160
out[2] = 0x14; // push 20
out.set(pkh, 3);
out[23] = 0x88; // OP_EQUALVERIFY
out[24] = 0xac; // OP_CHECKSIG
return out;
}
function divider() {
console.log('='.repeat(72));
}
async function ensureEligibleUtxos(wallet, needed) {
let utxos = await wallet.getUtxos();
let eligible = utxos.filter((u) => u.vout === 0 && !u.token);
console.log(`Wallet has ${utxos.length} UTXOs; ${eligible.length} eligible (vout=0, no token).`);
// Each splitter tx puts the recipient at vout=0 (eligible) and change
// at vout=1 (not eligible). To monotonically grow eligible count we
// must prefer to consume non-eligible UTXOs in subsequent splits —
// otherwise we'd wash (lose one eligible, gain one). On the first
// iteration (only an eligible UTXO available), we accept the wash and
// proceed: net change still 0, but next iter has a non-eligible UTXO
// to consume so we make progress.
const MAX_ITER = 8;
let iter = 0;
while (eligible.length < needed && iter < MAX_ITER) {
iter++;
const ineligible = utxos.filter((u) => !(u.vout === 0 && !u.token));
const restrictTo = ineligible.length > 0 ? ineligible : null;
console.log(
` Splitter ${iter}/${MAX_ITER}: consuming ${restrictTo ? `${ineligible.length} ineligible` : 'any available'} UTXOs...`,
);
const sendOptions = restrictTo ? { utxoIds: restrictTo } : undefined;
const result = await wallet.send(
[{ cashaddr: wallet.cashaddr, value: 2000n, unit: 'sat' }],
sendOptions,
);
const txId = result?.txId ?? result?.txid;
console.log(` TXID: ${txId}`);
utxos = await wallet.getUtxos();
eligible = utxos.filter((u) => u.vout === 0 && !u.token);
console.log(` Now ${eligible.length} eligible.`);
}
if (eligible.length < needed) {
throw new Error(`Could not produce ${needed} eligible UTXOs after ${iter} splits.`);
}
return eligible;
}
async function genesisOne(wallet, artifact, provider, params, utxo) {
const categoryId = utxo.txid; // CHIP-2022-02 — prevout TXID at input[0]
const tokenCategory = txidToConsensusBytes(categoryId);
const founderLockingBytecode = p2pkhLockingBytecode(wallet.publicKeyHash);
const contract = new Contract(
artifact,
[tokenCategory, params.rateSats, params.founderFeeBps, founderLockingBytecode],
{ provider },
);
console.log('');
divider();
console.log(`[${params.name}] rate=${params.rateSats} sats/unit, fee=${params.founderFeeBps} bps`);
divider();
console.log(` Genesis input UTXO: ${utxo.txid}:${utxo.vout} (${utxo.satoshis} sats)`);
console.log(` Category ID: ${categoryId}`);
console.log(` Contract address: ${contract.address}`);
console.log(` Contract token addr: ${contract.tokenAddress}`);
const result = await wallet.tokenGenesis(
{
cashaddr: contract.tokenAddress,
amount: RESERVOIR_FT_SUPPLY, // entire fixed supply parked at the contract
value: 1000n, // sats carried by the reservoir UTXO
nft: { capability: 'minting', commitment: '' },
},
[],
{ utxoIds: [utxo] },
);
const txId = result?.txId ?? result?.txid;
console.log(` Genesis TXID: ${txId}`);
return {
name: params.name,
rateSats: params.rateSats.toString(),
founderFeeBps: params.founderFeeBps.toString(),
initialReservoirFtSupply: RESERVOIR_FT_SUPPLY.toString(),
categoryId,
contractAddress: contract.address,
contractTokenAddress: contract.tokenAddress,
founderLockingBytecodeHex: binToHex(founderLockingBytecode),
genesisInputUtxo: {
txid: utxo.txid,
vout: utxo.vout,
satoshis: utxo.satoshis.toString(),
},
genesisTxId: txId,
};
}
async function main() {
if (existsSync(TOKENS_FILE)) {
const existing = JSON.parse(readFileSync(TOKENS_FILE, 'utf8'));
console.log('.chipnet-tokens.json already exists — refusing to re-genesis.');
console.log('Delete the file to re-run with fresh Category IDs.');
console.log('');
console.log('Existing tokens:');
for (const t of existing.tokens) {
console.log(` ${t.name}: rate=${t.rateSats} sats/unit, category=${t.categoryId}`);
console.log(` contract=${t.contractAddress}`);
}
return;
}
const { wif } = JSON.parse(readFileSync(KEYS_FILE, 'utf8'));
const wallet = await TestNetWallet.fromWIF(wif);
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const provider = new ElectrumNetworkProvider('chipnet');
const balance = await wallet.getBalance();
const sats = typeof balance === 'object' ? balance.sat : balance;
console.log(`Wallet balance: ${sats} sats`);
if (BigInt(sats) < 20_000n) {
throw new Error(`Need at least 20,000 sats to safely cover genesis x2 + splitter fees. Have ${sats}.`);
}
const eligible = await ensureEligibleUtxos(wallet, TOKENS.length);
const tokens = [];
for (let i = 0; i < TOKENS.length; i++) {
// Re-fetch eligible UTXOs before each genesis — the previous genesis
// consumed one, and the wallet's mempool state may have shifted.
const fresh = (await wallet.getUtxos()).filter((u) => u.vout === 0 && !u.token);
if (fresh.length === 0) {
// Out of eligible UTXOs mid-loop. Generate one more.
console.log(` Eligible UTXOs exhausted before ${TOKENS[i].name}; splitting again...`);
await ensureEligibleUtxos(wallet, 1);
const after = (await wallet.getUtxos()).filter((u) => u.vout === 0 && !u.token);
tokens.push(await genesisOne(wallet, artifact, provider, TOKENS[i], after[0]));
} else {
tokens.push(await genesisOne(wallet, artifact, provider, TOKENS[i], fresh[0]));
}
}
const payload = {
network: 'chipnet',
createdAt: new Date().toISOString(),
walletAddress: wallet.cashaddr,
tokens,
};
writeFileSync(TOKENS_FILE, JSON.stringify(payload, null, 2));
console.log('');
divider();
console.log(`Saved to ${TOKENS_FILE}`);
divider();
console.log('Ready for step 5 (chipnet-mint-A.mjs).');
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});