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

201 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});