aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/scheduled-refresh-test.js
blob: c4065142cb8dcbb4b9cae06d633e2ce929cada6d (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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// End-to-end test of the server-side backstop renewal (scheduled delegated
// refresh) on regtest:
//
//   receive -> register a standing order -> server HOLDS it out of rounds
//   -> scheduled height passes -> server runs the round with the wallet
//   offline -> wallet returns and claims -> spending the coin instead kills
//   its standing order -> a claim still works after the input has EXPIRED
//
// Usage: bun tools/ark/scheduled-refresh-test.js
// Needs the ark-regtest stack (bc, ark-electrs, captaind on :3535).

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 { ALICE, aliceReady } from './alice.js';

const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
const ESPLORA = process.env.ESPLORA_URL || '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} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
const tipHeight = () => Number(sh(`${BCLI} getblockcount`));

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

const store = { raw: null };
const storage = {
  load: () => (store.raw ? JSON.parse(store.raw) : null),
  save: (s) => { store.raw = JSON.stringify(s); },
};

const mnemonic = generateMnemonic(wordlist);
const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'");
const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();

// This test mines a coin's whole lifetime in step 5, which also expires the
// funding wallet's coins — aliceReady() refreshes them on the next run.
await aliceReady();

const receive = async (sat) => {
  const before = mgr.balance().spendableSat;
  sh(`${ALICE} -q send ${mgr.address()} "${sat} sat"`);
  for (let i = 0; i < 15 && mgr.balance().spendableSat < before + sat; i++) {
    await mgr.sync();
    await sleep(1000);
  }
  return mgr.balance().spendableSat - before;
};

// --- 1. register a standing order ---
console.log('\n[1] register');
check('received 20000 sat', (await receive(20000)) === 20000, JSON.stringify(mgr.balance()));

const coin = mgr.vtxos().find((v) => v.state === 'spendable');
// aim the backstop a few blocks out so the test doesn't have to mine a month
const runAt = tipHeight() + 3;
const n = await mgr.ensureScheduledRefreshes(coin.expiryHeight - runAt);
const entry = mgr.scheduledRefreshes()[0];
check('one standing order registered', n === 1 && mgr.scheduledRefreshes().length === 1);
check('has an unlock hash', !!entry?.unlockHash, entry?.unlockHash?.slice(0, 16));
check('scheduled at the asked height', entry?.scheduledHeight === runAt, `${entry?.scheduledHeight} vs ${runAt}`);
check('fee priced at the scheduled height',
  entry?.feeSat === mgr.refreshFee([coin], runAt), `${entry?.feeSat} sat`);
check('coin still spendable', mgr._vtxo(coin.id).state === 'spendable');
check('balance untouched', mgr.balance().spendableSat === 20000, JSON.stringify(mgr.balance()));
check('wallet is not busy', mgr.pendingActions().length === 0);

// --- 2. the server holds it until the scheduled height ---
console.log('\n[2] held out of rounds');
await sleep(25_000); // two-plus regtest rounds
await mgr.sync();
check('not claimed early', mgr.scheduledRefreshes().length === 1 && mgr.balance().spendableSat === 20000,
  JSON.stringify(mgr.balance()));
check('coin still spendable while held', mgr._vtxo(coin.id).state === 'spendable');

// --- 3. the round runs with the wallet offline, then the wallet returns ---
console.log('\n[3] runs offline, claimed on return');
mine(3 + 2); // past the scheduled height, plus confirmations for the round funding tx
await sleep(15_000); // let a round include it — no sync() in between: the wallet is "closed"
check('still nothing local while away', mgr.scheduledRefreshes().length === 1);
const claimed = () => mgr.state.actions.some((a) => a.type === 'refresh' && a.step === 'done');
for (let i = 0; i < 25 && !claimed(); i++) { await mgr.sync(); mine(1); await sleep(1500); }
const renewed = mgr.vtxos().find((v) => v.state === 'spendable' && v.id !== coin.id);
if (!renewed) console.log('   actions:', JSON.stringify(mgr.state.actions.map((a) => [a.type, a.step, a.lastError])));
check('standing order consumed', mgr.scheduledRefreshes().length === 0);
check('old coin spent', mgr._vtxo(coin.id).state === 'spent');
check('fresh coin adopted', !!renewed, renewed && `${renewed.amountSat} sat`);
check('balance is the coin minus the scheduled fee',
  mgr.balance().spendableSat === 20000 - entry.feeSat, JSON.stringify(mgr.balance()));
check('expiry clock restarted', !!renewed && renewed.expiryHeight > coin.expiryHeight,
  renewed && `${coin.expiryHeight} -> ${renewed.expiryHeight}`);
check('renewal in history', mgr.movements().some((m) => m.type === 'refresh' && m.status === 'complete'));

// --- 4. spending the coin kills its standing order ---
console.log('\n[4] spend voids the standing order');
const runAt2 = tipHeight() + 50; // far enough out that the round never runs during the test
await mgr.ensureScheduledRefreshes(renewed.expiryHeight - runAt2);
check('standing order for the fresh coin', mgr.scheduledRefreshes().length === 1);
await mgr.send(sh(`${ALICE} -q address`), 5000);
await mgr.sync();
check('dropped once the input is gone', mgr.scheduledRefreshes().length === 0);
check('no orphan action left behind', mgr.pendingActions().length === 0,
  JSON.stringify(mgr.pendingActions().map((a) => [a.type, a.step])));

// --- 5. a claim still works after the input has expired ---
console.log('\n[5] claim after the input expires');
const change = mgr.vtxos().find((v) => v.state === 'spendable');
const runAt3 = tipHeight() + 3;
await mgr.ensureScheduledRefreshes(change.expiryHeight - runAt3);
check('standing order for the change coin', mgr.scheduledRefreshes().length === 1);
mine(5);
await sleep(15_000); // the round runs — still no sync, the wallet is away
mine(change.expiryHeight - tipHeight() + 10); // ...and stays away past the coin's expiry
check('input is expired now', tipHeight() > change.expiryHeight, `tip ${tipHeight()} > ${change.expiryHeight}`);
const claimedAgain = () => mgr.state.actions.filter((a) => a.type === 'refresh' && a.step === 'done').length > 1;
for (let i = 0; i < 25 && !claimedAgain(); i++) { await mgr.sync(); mine(1); await sleep(1500); }
if (!claimedAgain()) console.log('   actions:', JSON.stringify(mgr.state.actions.map((a) => [a.type, a.step, a.lastError])));
const rescued = mgr.vtxos().find((v) => v.id !== change.id && v.state === 'spendable');
check('expired input forfeited, output claimed', mgr.scheduledRefreshes().length === 0 && !!rescued,
  rescued ? `${rescued.amountSat} sat, expiry ${rescued.expiryHeight}` : JSON.stringify(mgr.balance()));
check('expired input is spent, not stuck', mgr._vtxo(change.id).state === 'spent');
// The replacement's lifetime runs from the round, so it outlives the coin it
// replaced — by margin blocks less than a full term. (This test compresses a
// month into minutes, so the tip can already be past even the new expiry;
// what matters is that the claim reached a coin with a later clock.)
check('replacement carries a later expiry', !!rescued && rescued.expiryHeight > change.expiryHeight,
  rescued && `${change.expiryHeight} -> ${rescued.expiryHeight}`);
check('nothing left pending', mgr.balance().pendingSat === 0, JSON.stringify(mgr.balance()));

console.log(ok ? '\nALL GREEN\n' : '\nFAILURES\n');
process.exit(ok ? 0 : 1);