aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/expired-dust-test.js
blob: c23bd523058d40d2ea69bd324f9d8e185ba12085 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// Does the ASP keep honoring a sub-dust vtxo PAST its expiry height?
//
// Needs captaind running with a short vtxo_lifetime (e.g. 40 blocks) so the
// test can mine past expiry without aging the rest of the regtest chain.
//
//   board fresh coins -> dust to a bearer key -> mine past expiry (watchman
//   sweeps the board) -> bearer sweeps its expired dust to a claimer
//   -> claimer's client validates it (dust exemption) -> and for contrast,
//   an expired NON-dust spend, to document the server's actual policy.
//
// Usage: bun tools/ark/expired-dust-test.js

import { execSync } from 'node:child_process';
import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';

import { ArkManager } from '../../src/ark/manager.js';
import { getArkInfo } from '../../src/ark/proto.js';

const ARK = 'http://127.0.0.1:3535';
const ESPLORA = 'http://127.0.0.1:30002';
const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
const mine = (n = 1) => sh(`${BCLI} -generate ${n} >/dev/null`);

let ok = true;
const check = (name, cond, detail = '') => {
  console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
  if (!cond) ok = false;
};

const newWallet = async () => {
  const store = { raw: null };
  const storage = { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } };
  const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'");
  return new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
};
const waitBalance = async (mgr, want, tries = 15) => {
  for (let i = 0; i < tries && mgr.balance().spendableSat < want; i++) { await mgr.sync(); await sleep(1500); }
  return mgr.balance().spendableSat;
};

// Mining past a normal lifetime (4320 blocks) would expire every other coin on
// the shared regtest chain, so without the short-lifetime captaind this skips.
const { vtxoExpiryDelta } = await getArkInfo(ARK);
if (vtxoExpiryDelta > 60) {
  console.log(`SKIP: needs captaind with a short vtxo_lifetime (<= 60 blocks); this one has ${vtxoExpiryDelta}`);
  process.exit(0);
}

console.log('[setup] board fresh coins under the short lifetime');
const S = await newWallet();
const G1 = await newWallet(); // 1-sat bearer "gift" key
const C = await newWallet(); // claimer
check('server lifetime is short', S.info.vtxoExpiryDelta <= 60, `vtxoExpiryDelta=${S.info.vtxoExpiryDelta}`);

const { actionId, fundingAddress, feeSat } = await S.startBoard(20000);
const raw = sh(`${BCLI} createrawtransaction '[]' '[{"${fundingAddress}":0.00020000}]'`);
const funded = JSON.parse(sh(`${BCLI} fundrawtransaction ${raw} '{"changePosition":1}'`));
const signed = JSON.parse(sh(`${BCLI} signrawtransactionwithwallet ${funded.hex}`));
const fundingTxid = sh(`${BCLI} sendrawtransaction ${signed.hex}`);
await S.completeBoard(actionId, fundingTxid);
mine(2);
for (let i = 0; i < 20 && S.pendingActions().length; i++) { await sleep(2000); await S.sync(); }
check('boarded', S.balance().spendableSat === 20000 - feeSat, JSON.stringify(S.balance()));

console.log('\n[pre-expiry] 1-sat gift to the bearer key');
await S.send(G1.address(), 1);
check('bearer holds 1 sat', (await waitBalance(G1, 1)) === 1);
const expiry = G1.vtxos()[0].expiryHeight;
let tip = await S.chain.tipHeight();
console.log(`   dust vtxo expires at ${expiry}, tip ${tip}`);

console.log('\n[expire] mining past expiry, waiting for the watchman to CLAIM the board');
mine(expiry - tip + 5);
// esplora indexes the fresh blocks with a lag — poll until it catches up
for (let i = 0; i < 20 && (tip = await S.chain.tipHeight()) <= expiry; i++) await sleep(1500);
check('tip is past expiry', tip > expiry, `tip ${tip} vs expiry ${expiry}`);
// the watchman's claim spends the board funding output — poll for it, mining
// now and then so its claim tx also confirms (process_interval is 60s)
let sweptAt = null;
for (let i = 0; i < 15 && !sweptAt; i++) {
  await sleep(20000);
  mine(1);
  try {
    const o = await fetch(`${ESPLORA}/tx/${fundingTxid}/outspend/0`).then((r) => r.json());
    if (o.spent) sweptAt = o.txid;
  } catch {}
}
check('watchman claimed the expired board on-chain', !!sweptAt, sweptAt || 'not swept after 5 min — claim below tests pre-sweep behavior only');

console.log('\n[claim] bearer sweeps its EXPIRED dust to the claimer');
let claimErr = null;
try { await G1.send(C.address(), 1); } catch (e) { claimErr = e.message; }
check('server cosigned the expired-dust claim', !claimErr, claimErr || '');
if (!claimErr) {
  const got = await waitBalance(C, 1);
  check('claimer validated + received the expired dust', got === 1, `${got} sat`);
}

console.log('\n[contrast] expired NON-dust spend from the sender');
const alice = `${process.env.HOME}/bark/target/debug/bark --datadir ${process.env.HOME}/ark-regtest/alice`;
let bigErr = null;
try { await S.send(sh(`${alice} -q address`), 1000); } catch (e) { bigErr = e.message; }
console.log(`   server ${bigErr ? 'REJECTED: ' + bigErr : 'accepted the expired non-dust spend'}`);

console.log(ok ? '\n✅ expired dust stays spendable' : '\n❌ see failures above');
process.exit(ok ? 0 : 1);