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>
201 lines
7.7 KiB
JavaScript
201 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* chipnet-mut-claim.mjs — claim N units from a deployed mutability-test
|
||
* contract on behalf of a user wallet.
|
||
*
|
||
* Env:
|
||
* LABEL (required) — the contract label saved by chipnet-mut-deploy
|
||
* USER (default b) — wallet a|b|c that pays the BCH and receives the FT
|
||
* AMOUNT (required) — token units to claim
|
||
* MINER_FEE (default 5000) — miner fee in sats (V2 §6.4 floor)
|
||
*
|
||
* Reads `ChipnetTests/.chipnet-mut-state.json`. Appends the claim event under that
|
||
* label. Idempotent only across distinct invocations (each new claim is
|
||
* a new event).
|
||
*/
|
||
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-mut-state.json');
|
||
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
|
||
|
||
const LABEL = process.env.LABEL;
|
||
const USER = (process.env.USER ?? 'b').toLowerCase();
|
||
const AMOUNT = process.env.AMOUNT ? BigInt(process.env.AMOUNT) : null;
|
||
// Base miner fee — scales up below if the picker selects many inputs.
|
||
const MINER_FEE_BASE = BigInt(process.env.MINER_FEE ?? '5000');
|
||
|
||
if (!LABEL) throw new Error('LABEL env var is required');
|
||
if (!AMOUNT) throw new Error('AMOUNT env var is required');
|
||
if (AMOUNT <= 0n) throw new Error('AMOUNT must be > 0');
|
||
|
||
const KEYS = {
|
||
a: join(ROOT, '.chipnet-keys.json'),
|
||
b: join(ROOT, '.chipnet-keys-b.json'),
|
||
c: join(ROOT, '.chipnet-keys-c.json'),
|
||
};
|
||
if (!KEYS[USER]) throw new Error(`USER must be a/b/c, got ${USER}`);
|
||
|
||
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 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));
|
||
|
||
async function main() {
|
||
if (!existsSync(STATE_FILE)) throw new Error(`state file ${STATE_FILE} not found — run chipnet-mut-deploy first`);
|
||
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
||
const c = state.contracts?.[LABEL];
|
||
if (!c) throw new Error(`no contract with label "${LABEL}" in state`);
|
||
|
||
const { wif } = JSON.parse(readFileSync(KEYS[USER], 'utf8'));
|
||
const user = await TestNetWallet.fromWIF(wif);
|
||
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
|
||
const provider = new ElectrumNetworkProvider('chipnet');
|
||
const sigTemplate = new SignatureTemplate(user.privateKey);
|
||
|
||
const tokenCategory = txidToConsensusBytes(c.categoryId);
|
||
const founderLockingBytecode = hexToBin(c.founderLockingBytecodeHex);
|
||
const rateSats = BigInt(c.rateSats);
|
||
const feeBps = BigInt(c.feeBps);
|
||
const contract = new Contract(
|
||
artifact,
|
||
[tokenCategory, rateSats, feeBps, founderLockingBytecode],
|
||
{ provider },
|
||
);
|
||
if (contract.tokenAddress !== c.contractTokenAddress) {
|
||
throw new Error('contract address mismatch — reconstruction failed (param drift?)');
|
||
}
|
||
|
||
const grossSats = AMOUNT * rateSats;
|
||
const feeSats = (grossSats * feeBps) / 10_000n;
|
||
if (feeBps > 0n && feeSats < 546n) {
|
||
throw new Error(`feeSats=${feeSats} below dust 546 — increase AMOUNT or accept fee rounding`);
|
||
}
|
||
|
||
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
|
||
const reservoir = reservoirUtxos.find(
|
||
(u) =>
|
||
u.token?.nft?.capability === 'minting' &&
|
||
u.token?.category === c.categoryId &&
|
||
u.token?.amount > 0n,
|
||
);
|
||
if (!reservoir) throw new Error(`reservoir not found at ${contract.tokenAddress}`);
|
||
|
||
const userUtxos = await provider.getUtxos(user.cashaddr);
|
||
const bchUtxos = userUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
|
||
|
||
// Two-pass picker: first pass with base fee, then re-estimate based on
|
||
// input count (chipnet relay floor is 1 sat/byte; each P2PKH input ≈
|
||
// 150 bytes). Adds a 1.5× safety margin on top of the estimate.
|
||
function pick(minerFee) {
|
||
const target = grossSats + feeSats + minerFee + 1000n;
|
||
let picked = [];
|
||
let pickedTotal = 0n;
|
||
for (const u of bchUtxos) {
|
||
picked.push(u);
|
||
pickedTotal += BigInt(u.satoshis);
|
||
if (pickedTotal >= target) break;
|
||
}
|
||
return { picked, pickedTotal, target };
|
||
}
|
||
function estimateMinerFee(picked) {
|
||
const overheadBytes = 200n;
|
||
const perInputBytes = 150n;
|
||
const perOutputBytes = 40n;
|
||
const outputCount = feeBps > 0n ? 4n : 3n;
|
||
const sizeBytes = overheadBytes + BigInt(picked.length + 1) * perInputBytes + outputCount * perOutputBytes;
|
||
const naive = sizeBytes; // 1 sat/byte chipnet floor
|
||
const withSafety = (naive * 3n) / 2n; // 1.5× safety margin
|
||
return withSafety > MINER_FEE_BASE ? withSafety : MINER_FEE_BASE;
|
||
}
|
||
|
||
// First pass with base fee
|
||
let { picked, pickedTotal, target } = pick(MINER_FEE_BASE);
|
||
let MINER_FEE = estimateMinerFee(picked);
|
||
// Re-pick if estimated fee exceeds base (we may need more inputs)
|
||
if (MINER_FEE > MINER_FEE_BASE) {
|
||
({ picked, pickedTotal, target } = pick(MINER_FEE));
|
||
// Re-estimate one more time in case the picker grew
|
||
MINER_FEE = estimateMinerFee(picked);
|
||
({ picked, pickedTotal, target } = pick(MINER_FEE));
|
||
}
|
||
if (pickedTotal < target) {
|
||
throw new Error(`User ${USER.toUpperCase()} BCH ${pickedTotal} < ${target} sats needed (picked ${picked.length} UTXOs, est miner fee ${MINER_FEE})`);
|
||
}
|
||
const change = BigInt(reservoir.satoshis) + pickedTotal - 1000n - grossSats - feeSats - MINER_FEE;
|
||
if (change < 546n) throw new Error(`change ${change} < dust`);
|
||
const recipientLockingBytecode = p2pkhLockingBytecode(user.publicKeyHash);
|
||
const reservoirRemaining = reservoir.token.amount - AMOUNT;
|
||
|
||
console.log(`[mut-claim] ${LABEL}: USER=${USER.toUpperCase()} claims ${AMOUNT} units`);
|
||
console.log(` rate=${rateSats} feeBps=${feeBps} grossSats=${grossSats} feeSats=${feeSats} minerFee=${MINER_FEE}`);
|
||
|
||
const tb = new TransactionBuilder({ provider });
|
||
tb.addInput(reservoir, contract.unlock.claim(AMOUNT, recipientLockingBytecode));
|
||
for (const u of picked) tb.addInput(u, sigTemplate.unlockP2PKH());
|
||
tb.addOutput({
|
||
to: contract.tokenAddress,
|
||
amount: 1000n,
|
||
token: {
|
||
amount: reservoirRemaining,
|
||
category: c.categoryId,
|
||
nft: { capability: 'minting', commitment: '' },
|
||
},
|
||
});
|
||
tb.addOutput({
|
||
to: user.tokenaddr,
|
||
amount: grossSats,
|
||
token: { amount: AMOUNT, category: c.categoryId },
|
||
});
|
||
if (feeSats > 0n) {
|
||
tb.addOutput({ to: c.founderAddress, amount: feeSats });
|
||
}
|
||
tb.addOutput({ to: user.cashaddr, amount: change });
|
||
|
||
const sent = await tb.send();
|
||
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
|
||
console.log(` claim TXID: ${txid}`);
|
||
|
||
c.claims = c.claims ?? [];
|
||
c.claims.push({
|
||
user: USER.toUpperCase(),
|
||
amount: AMOUNT.toString(),
|
||
grossSats: grossSats.toString(),
|
||
feeSats: feeSats.toString(),
|
||
minerFee: MINER_FEE.toString(),
|
||
txid,
|
||
at: new Date().toISOString(),
|
||
});
|
||
state.events.push({ action: 'claim', label: LABEL, user: USER.toUpperCase(), amount: AMOUNT.toString(), txid });
|
||
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||
await sleep(8_000);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('FAILED:', err?.message ?? err);
|
||
if (err?.stack) console.error(err.stack);
|
||
process.exit(1);
|
||
});
|