#!/usr/bin/env node /** * chipnet-exp-dra-batch-test.mjs — BOUNDED-UNROLL batch proof (DeviantExpDRA.claim3). * * Buys 3 exponential-DRA units in ONE tx (x2 doubling). Each unit is self-backed * at its own mint rate: r, r*m, r*m^2 = 1000, 2000, 4000. The reservoir commitment * advances r -> r*m^3 = 8000 in the single tx. Then a follow-up single claim() * proves the curve continues seamlessly from 8000 (i.e. claim3 left identical * state to three sequential claim() calls). Buyer = B. Writes * .chipnet-exp-dra-batch-test.json. * Run: node --import tsx scripts/chipnet-exp-dra-batch-test.mjs --yes */ 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-exp-dra-batch-test.json'); const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantExpDRA.json'); const KEYS = join(ROOT, '.chipnet-keys-b.json'); const START_RATE = 1000n, MULTIPLIER = 2n, SUPPLY = 20n, POSITION_VALUE = 1000n; const hexToBin = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; }; const binToHex = (b) => Buffer.from(b).toString('hex'); const txidToConsensusBytes = (h) => { const d = hexToBin(h); const o = new Uint8Array(d.length); for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i]; return o; }; const p2pkh = (pkh) => { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(pkh, 3); o[23] = 0x88; o[24] = 0xac; return o; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function scriptNum(n) { if (n === 0n) return new Uint8Array([]); let v = n; const o = []; while (v > 0n) { o.push(Number(v & 0xffn)); v >>= 8n; } if (o[o.length - 1] & 0x80) o.push(0x00); return Uint8Array.from(o); } const rateHex = (n) => binToHex(scriptNum(n)); function decodeNum(hex) { const b = hexToBin(hex); let p = 0n; for (let k = b.length - 1; k >= 0; k--) p = (p << 8n) | BigInt(b[k] & (k === b.length - 1 ? 0x7f : 0xff)); return p; } async function ensureEligibleUtxo(w) { let u = await w.getUtxos(); let e = u.filter((x) => x.vout === 0 && !x.token); if (e.length) return e[0]; const bad = u.filter((x) => !(x.vout === 0 && !x.token)); const r = await w.send([{ cashaddr: w.cashaddr, value: 5000n, unit: 'sat' }], bad.length ? { utxoIds: bad } : undefined); console.log(` splitter ${r?.txId ?? r?.txid}`); await sleep(12_000); u = await w.getUtxos(); e = u.filter((x) => x.vout === 0 && !x.token); if (!e.length) throw new Error('no eligible vout-0'); return e[0]; } function pickCash(cash, need) { const picked = []; let tot = 0n; for (const u of cash) { picked.push(u); tot += BigInt(u.satoshis); if (tot >= need) break; } if (tot < need) throw new Error(`insufficient ${tot} < ${need}`); return { picked, tot }; } async function main() { if (!process.argv.includes('--yes')) { console.log('Re-run with --yes.'); return; } const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8')); const w = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS, 'utf8')).wif); const provider = new ElectrumNetworkProvider('chipnet'); const sig = new SignatureTemplate(w.privateKey); const recipientLB = p2pkh(w.publicKeyHash); const ctor = (cat) => [txidToConsensusBytes(cat), MULTIPLIER]; const state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : {}; // --- genesis --- if (!state.categoryId) { const utxo = await ensureEligibleUtxo(w); const cat = utxo.txid; const contract = new Contract(artifact, ctor(cat), { provider }); console.log(`contract ${contract.tokenAddress}\ncategory ${cat}`); const r = await w.tokenGenesis( { cashaddr: contract.tokenAddress, amount: SUPPLY, value: POSITION_VALUE, nft: { capability: 'mutable', commitment: rateHex(START_RATE) } }, [], { utxoIds: [utxo] }); console.log(`genesis ${r?.txId ?? r?.txid} (rate ${START_RATE}, x${MULTIPLIER})`); state.categoryId = cat; state.contractTokenAddress = contract.tokenAddress; writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); await sleep(10_000); } const contract = new Contract(artifact, ctor(state.categoryId), { provider }); // --- claim3: buy 3 units in one tx --- if (!state.batch) { const ru = await provider.getUtxos(contract.tokenAddress); const pos = ru.find((u) => u.token?.nft && u.token?.category === state.categoryId && u.token?.amount > 0n); if (!pos) throw new Error('reservoir not found'); const rate = decodeNum(pos.token.nft.commitment); const r1 = rate, r2 = rate * MULTIPLIER, r3 = r2 * MULTIPLIER, finalRate = r3 * MULTIPLIER; console.log(`\nclaim3: rates ${r1} + ${r2} + ${r3} = ${r1 + r2 + r3} (geometric sum) → commitment ${finalRate}`); const cash = (await provider.getUtxos(w.cashaddr)).filter((u) => !u.token).sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis))); const minerFee = 3000n; const sumUnits = r1 + r2 + r3; const { picked, tot } = pickCash(cash, sumUnits + minerFee + 1000n); const change = BigInt(pos.satoshis) + tot - POSITION_VALUE - sumUnits - minerFee; const tb = new TransactionBuilder({ provider }); tb.addInput(pos, contract.unlock.claim3(recipientLB)); for (const u of picked) tb.addInput(u, sig.unlockP2PKH()); tb.addOutput({ to: contract.tokenAddress, amount: POSITION_VALUE, token: { amount: BigInt(pos.token.amount) - 3n, category: state.categoryId, nft: { capability: 'mutable', commitment: rateHex(finalRate) } } }); tb.addOutput({ to: w.tokenaddr, amount: r1, token: { amount: 1n, category: state.categoryId } }); tb.addOutput({ to: w.tokenaddr, amount: r2, token: { amount: 1n, category: state.categoryId } }); tb.addOutput({ to: w.tokenaddr, amount: r3, token: { amount: 1n, category: state.categoryId } }); if (change >= 546n) tb.addOutput({ to: w.cashaddr, amount: change }); const sent = await tb.send(); const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId; console.log(` ✓ minted 3 units @ ${r1}/${r2}/${r3} (each self-backed) in ONE tx · next rate ${finalRate} · ${txid}`); state.batch = { rates: [r1, r2, r3].map(String), total: sumUnits.toString(), nextRate: finalRate.toString(), txid }; writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); await sleep(9_000); } // --- follow-up single claim: prove curve continues from the batch's end state --- if (!state.followup) { const ru = await provider.getUtxos(contract.tokenAddress); const pos = ru.find((u) => u.token?.nft && u.token?.category === state.categoryId && u.token?.amount > 0n); if (!pos) throw new Error('reservoir not found (followup)'); const rate = decodeNum(pos.token.nft.commitment); console.log(`\nfollow-up claim: rate ${rate} (should be ${state.batch.nextRate}) → next ${rate * MULTIPLIER}`); const cash = (await provider.getUtxos(w.cashaddr)).filter((u) => !u.token).sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis))); const minerFee = 2000n; const { picked, tot } = pickCash(cash, rate + minerFee + 1000n); const change = BigInt(pos.satoshis) + tot - POSITION_VALUE - rate - minerFee; const tb = new TransactionBuilder({ provider }); tb.addInput(pos, contract.unlock.claim(recipientLB)); for (const u of picked) tb.addInput(u, sig.unlockP2PKH()); tb.addOutput({ to: contract.tokenAddress, amount: POSITION_VALUE, token: { amount: BigInt(pos.token.amount) - 1n, category: state.categoryId, nft: { capability: 'mutable', commitment: rateHex(rate * MULTIPLIER) } } }); tb.addOutput({ to: w.tokenaddr, amount: rate, token: { amount: 1n, category: state.categoryId } }); if (change >= 546n) tb.addOutput({ to: w.cashaddr, amount: change }); const sent = await tb.send(); const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId; console.log(` ✓ single claim @ ${rate} · next ${rate * MULTIPLIER} · ${txid}`); state.followup = { rate: rate.toString(), next: (rate * MULTIPLIER).toString(), txid }; writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); } console.log(`\n=== RESULT — bounded-unroll batch (claim3, x${MULTIPLIER}) ===`); console.log(` batch 3 units: ${state.batch.rates.join(' + ')} = ${state.batch.total} sats, ONE tx (${state.batch.txid})`); console.log(` reservoir commitment after batch: ${state.batch.nextRate}`); console.log(` follow-up claim @ ${state.followup.rate} → next ${state.followup.next} (${state.followup.txid})`); console.log(` curve continuity: batch left rate ${state.batch.nextRate}, follow-up consumed ${state.followup.rate} ✓`); } main().catch((e) => { console.error('FAILED:', e?.message ?? e); if (e?.stack) console.error(e.stack); process.exit(1); });