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>
169 lines
9.9 KiB
JavaScript
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); });
|