deviant/scripts/chipnet-adjustable-fee-test.mjs
Local Dev 6b20b009a9 snapshot(0259): asm adjustable fee-with-cap covenant + wallet-integration handoff
Realises ADR-0011's two-knob fee model on chain: feeBps is mutable (carried in
the reservoir NFT's commitment, rotated by a founder-signed updateFee), bounded
by immutable maxFeeBps constructor arg. Reuses the "carried-rate trick" from
snapshot 0128 (DeviantExpDRA). Single contract address forever — holders never
migrate on fee changes.

- contracts/DeviantAdjustableFeeCapped.cash — new covenant (mutable NFT + cap).
- scripts/chipnet-adjustable-fee-test.mjs — 6-scenario chipnet test, 6/6 pass,
  two negatives rejected on-chain at the exact require() lines (cap + auth).
- snapshots/0259-*.md — full write-up, evidence table, design comparison,
  cross-refs to ADR-0011/0029 and snapshots 0128/0089.
- Parameters/Artemision/SESSION_PROMPT_adjustable-fee-wallet-integration.md —
  self-contained parallel-session brief to wire the template into wallet UI
  (types → portable → D-1 → live-fee claim → wizard genesis → founder-only
  updateFee action), following the Route B (snapshot 0089) pattern.
- Parameters/dApp/Agora/README.md — Agora folder index pointing to the
  Artemision session prompt (per user note: Agora should keep links to
  Artemision for cross-domain discoverability).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-02 00:23:18 +02:00

169 lines
9.9 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-adjustable-fee-test.mjs — end-to-end test of DeviantAdjustableFeeCapped.
*
* The two-knob model realised: mutable feeBps carried in the reservoir NFT's
* commitment, immutable maxFeeBps baked into constructor. Founder can update the
* fee anytime (signed) but never above the cap.
*
* Roles: A = founder + genesis funder ; C = claimer 1 ; D = claimer 2 ;
* E = adversary (tries unauthorised update).
*
* Scenarios (all on chipnet, real broadcasts):
* 1. Deploy at feeBps = 100 (1%), maxFeeBps = 500 (5%)
* 2. C claims 600 units → fee = 60,000 * 100 / 10,000 = 600 bits
* 3. Founder updateFee(300) [expect OK]
* 4. D claims 600 units → fee = 60,000 * 300 / 10,000 = 1,800 bits
* 5. Founder updateFee(600) — exceeds maxFeeBps=500 [expect FAIL]
* 6. Adversary E tries updateFee(50) with their own key [expect FAIL]
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { binToHex } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/DeviantAdjustableFeeCapped.json'), 'utf8'));
const RATE_SATS = 100n, MAX_FEE_BPS = 500n, START_FEE_BPS = 100n, NEW_FEE_BPS = 300n, BAD_FEE_BPS = 600n;
const RESERVOIR_SUPPLY = 1_000_000_000n, CLAIM_UNITS = 600n, MINER = 3000n;
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 rev = (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 p2 = (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; };
// ScriptNum encoding (variable-length little-endian, MSB→sign-pad). Zero → empty.
function scriptNum(n) { if (n === 0n) return new Uint8Array([]); let v = n < 0n ? -n : n; const o = []; while (v > 0n) { o.push(Number(v & 0xffn)); v >>= 8n; } if (o[o.length - 1] & 0x80) o.push(0x00); if (n < 0n) o[o.length - 1] |= 0x80; return Uint8Array.from(o); }
const feeHex = (n) => binToHex(scriptNum(n));
function decodeNum(hex) { if (!hex) return 0n; const b = hexToBin(hex); let v = 0n; for (let i = b.length - 1; i >= 0; i--) { v = (v << 8n) | BigInt(b[i]); } return v; }
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const KEY = (w) => join(ROOT, w === 'a' ? '.chipnet-keys.json' : `.chipnet-keys-${w}.json`);
const label = (t) => `\n═══ ${t} ${'═'.repeat(Math.max(4, 70 - t.length))}`;
async function ensureVout0(wallet, provider) {
let utxos = await provider.getUtxos(wallet.cashaddr);
let eligible = utxos.filter((u) => u.vout === 0 && !u.token);
if (eligible.length > 0) return eligible[0];
console.log(' no vout=0 UTXO — self-sending to create one…');
await wallet.send([{ cashaddr: wallet.cashaddr, value: 5000n, unit: 'sat' }]);
await sleep(1500);
utxos = await provider.getUtxos(wallet.cashaddr);
eligible = utxos.filter((u) => u.vout === 0 && !u.token);
if (eligible.length === 0) throw new Error('still no vout=0 UTXO after self-send');
return eligible[0];
}
async function main() {
const provider = new ElectrumNetworkProvider('chipnet');
const [wifA, wifC, wifD, wifE] = ['a', 'c', 'd', 'e'].map((w) => JSON.parse(readFileSync(KEY(w), 'utf8')).wif);
const [founder, claimerC, claimerD, adversary] = await Promise.all(
[wifA, wifC, wifD, wifE].map((w) => TestNetWallet.fromWIF(w)),
);
const founderSig = new SignatureTemplate(founder.privateKey);
const adversarySig = new SignatureTemplate(adversary.privateKey);
const founderLB = p2(founder.publicKeyHash);
const founderPubKey = founder.publicKeyCompressed;
console.log(label('SETUP'));
console.log(` founder = A pubkey ${binToHex(founderPubKey).slice(0, 16)}…`);
console.log(` claimers = C, D`);
console.log(` adversary = E`);
console.log(label('1) DEPLOY at feeBps = 100 (1%), maxFeeBps = 500 (5%)'));
const genIn = await ensureVout0(founder, provider);
const category = genIn.txid;
const contract = new Contract(
ARTIFACT,
[rev(category), RATE_SATS, MAX_FEE_BPS, founderPubKey, founderLB],
{ provider },
);
console.log(` category ${category.slice(0, 16)}…`);
console.log(` contract ${contract.tokenAddress.slice(0, 40)}…`);
const genRes = await founder.tokenGenesis({
cashaddr: contract.tokenAddress,
amount: RESERVOIR_SUPPLY,
value: 1000n,
nft: { capability: 'mutable', commitment: feeHex(START_FEE_BPS) },
}, [], { utxoIds: [genIn] });
const genTxid = txidOf(genRes);
console.log(` genesis tx ${genTxid}`);
console.log(` initial commitment = ${feeHex(START_FEE_BPS)} → feeBps ${START_FEE_BPS}`);
await sleep(2000);
// Helper: fetch the CURRENT reservoir UTXO by (categoryId + minting/mutable NFT).
async function fetchReservoir() {
const utxos = await provider.getUtxos(contract.tokenAddress);
const r = utxos.find((u) => u.token?.category === category && u.token?.nft);
if (!r) throw new Error('reservoir not found');
return r;
}
// Helper: run a claim with a specific claimer + expected fee.
async function doClaim(claimerWallet, whoLabel, expectFeeBps) {
const claimerSigT = new SignatureTemplate(claimerWallet.privateKey);
const res = await fetchReservoir();
const onChainFee = decodeNum(res.token.nft.commitment);
console.log(` reservoir commitment on chain = ${res.token.nft.commitment || '(empty)'} → feeBps ${onChainFee}`);
const gross = CLAIM_UNITS * RATE_SATS;
const feeSats = gross * onChainFee / 10_000n;
console.log(` gross ${gross} fee ${feeSats} bits (${onChainFee}bps) expected feeBps ${expectFeeBps}`);
if (onChainFee !== expectFeeBps) throw new Error(`fee mismatch: on-chain ${onChainFee} vs expected ${expectFeeBps}`);
const bch = (await provider.getUtxos(claimerWallet.cashaddr)).filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis))[0];
if (!bch) throw new Error(`${whoLabel} has no BCH`);
const change = BigInt(res.satoshis) + BigInt(bch.satoshis) - 1000n - gross - feeSats - MINER;
if (change < 546n) throw new Error(`change ${change} < dust`);
const tb = new TransactionBuilder({ provider });
tb.addInput(res, contract.unlock.claim(CLAIM_UNITS, p2(claimerWallet.publicKeyHash)));
tb.addInput(bch, claimerSigT.unlockP2PKH());
tb.addOutput({ to: contract.tokenAddress, amount: 1000n, token: { amount: res.token.amount - CLAIM_UNITS, category, nft: { capability: 'mutable', commitment: res.token.nft.commitment } } });
tb.addOutput({ to: claimerWallet.tokenaddr, amount: gross, token: { amount: CLAIM_UNITS, category } });
if (feeSats > 0n) tb.addOutput({ to: founder.cashaddr, amount: feeSats });
tb.addOutput({ to: claimerWallet.cashaddr, amount: change });
const txid = txidOf(await tb.send());
console.log(` ✓ ${whoLabel} claim ${txid.slice(0, 16)}… fee ${feeSats} → founder`);
await sleep(1500);
}
// Helper: founder updateFee — signed by a chosen wallet (may or may not be founder).
async function doUpdate(signerWallet, whoLabel, newFeeBps, sigTpl) {
const res = await fetchReservoir();
const bch = (await provider.getUtxos(signerWallet.cashaddr)).filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis))[0];
if (!bch) throw new Error(`${whoLabel} has no BCH`);
const change = BigInt(res.satoshis) + BigInt(bch.satoshis) - 1000n - MINER;
const tb = new TransactionBuilder({ provider });
tb.addInput(res, contract.unlock.updateFee(newFeeBps, sigTpl));
tb.addInput(bch, sigTpl.unlockP2PKH());
tb.addOutput({ to: contract.tokenAddress, amount: 1000n, token: { amount: res.token.amount, category, nft: { capability: 'mutable', commitment: feeHex(newFeeBps) } } });
if (change >= 546n) tb.addOutput({ to: signerWallet.cashaddr, amount: change });
const txid = txidOf(await tb.send());
console.log(` ✓ ${whoLabel} updateFee(${newFeeBps}) ${txid.slice(0, 16)}… commitment → ${feeHex(newFeeBps) || '(empty)'}`);
await sleep(1500);
}
console.log(label('2) Claimer C claims 600 units — expect fee 600 bits (1%)'));
await doClaim(claimerC, 'C', START_FEE_BPS);
console.log(label('3) Founder updateFee(300) — 3%, within cap'));
await doUpdate(founder, 'founder A', NEW_FEE_BPS, founderSig);
console.log(label('4) Claimer D claims 600 units — expect fee 1,800 bits (3%)'));
await doClaim(claimerD, 'D', NEW_FEE_BPS);
console.log(label('5) Founder updateFee(600) — 6%, EXCEEDS cap 5% (expect FAIL)'));
let neg1 = null;
try { await doUpdate(founder, 'founder A', BAD_FEE_BPS, founderSig); }
catch (e) { neg1 = (e?.message ?? String(e)).replace(/\s+/g, ' ').slice(0, 200); }
console.log(neg1 ? ` ✓ REJECTED as designed: ${neg1}` : ` ✗ UNEXPECTED SUCCESS`);
console.log(label('6) Adversary E updateFee(50) — bad signature (expect FAIL)'));
let neg2 = null;
try { await doUpdate(adversary, 'adversary E', 50n, adversarySig); }
catch (e) { neg2 = (e?.message ?? String(e)).replace(/\s+/g, ' ').slice(0, 200); }
console.log(neg2 ? ` ✓ REJECTED as designed: ${neg2}` : ` ✗ UNEXPECTED SUCCESS`);
console.log(label('RESULT'));
const pass = neg1 && neg2;
console.log(pass
? ` Adjustable-fee-with-cap PROVEN on chipnet: feeBps is mutable via founder-signed updateFee,\n strictly bounded by the immutable maxFeeBps cap; unauthorised updates rejected.`
: ` Some negative test did not fail as expected — inspect output above.`);
}
main().then(() => process.exit(0)).catch((e) => { console.error('ERROR:', e?.message ?? e); if (e?.stack) console.error(e.stack.split('\n').slice(0, 4).join('\n')); process.exit(1); });