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>
253 lines
10 KiB
JavaScript
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);
|
|
});
|