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);
|