aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark
diff options
context:
space:
mode:
Diffstat (limited to 'tools/ark')
-rw-r--r--tools/ark/manager-test.js4
-rw-r--r--tools/ark/scheduled-refresh-test.js151
2 files changed, 153 insertions, 2 deletions
diff --git a/tools/ark/manager-test.js b/tools/ark/manager-test.js
index 902d757..74a1c5c 100644
--- a/tools/ark/manager-test.js
+++ b/tools/ark/manager-test.js
@@ -15,7 +15,7 @@ import { HDKey } from '@scure/bip32';
import { hex } from '@scure/base';
import { ArkManager } from '../../src/ark/manager.js';
-import { decodeVtxo } from '../../src/ark/proto.js';
+import { decodeVtxo, vtxoBytesFromStr } from '../../src/ark/proto.js';
import { validateVtxo } from '../../src/ark/validate.js';
const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
@@ -59,7 +59,7 @@ check('movement logged', mgr.movements().some((m) => m.type === 'receive' && m.s
// tampered vtxo must fail validation
const rec = mgr.vtxos()[0];
-const tampered = hex.decode(rec.bytes);
+const tampered = vtxoBytesFromStr(rec.bytes); // stored base64 since the IndexedDB move
tampered[tampered.length - 100] ^= 0xff; // corrupt inside the genesis data
let rejected = false;
try {
diff --git a/tools/ark/scheduled-refresh-test.js b/tools/ark/scheduled-refresh-test.js
new file mode 100644
index 0000000..bb030c4
--- /dev/null
+++ b/tools/ark/scheduled-refresh-test.js
@@ -0,0 +1,151 @@
+// 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';
+
+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 BARK = `${process.env.HOME}/bark/target/debug/bark`;
+const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
+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 — an expired vtxo can be refreshed but not sent, so
+// re-running against a chain that already ran it would fund nothing.
+const fundingReady = () => {
+ const alice = JSON.parse(sh(`${ALICE} -q vtxos`));
+ const tip = tipHeight();
+ if (!alice.some((v) => v.expiry_height <= tip)) return;
+ console.log(' … refreshing the funding wallet\'s expired coins first');
+ sh(`${ALICE} refresh --all >/dev/null`);
+ mine(3);
+ sh(`${ALICE} -q balance >/dev/null`);
+};
+fundingReady();
+
+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);