// End-to-end test of the persistent ArkManager on regtest: // // receive (full validation) -> send -> CRASH mid-send + resume from storage // -> board -> refresh/consolidate -> spend consolidated -> reload wallet // // Storage is JSON-round-tripped on every save (like localStorage would), // and "restarts" build a brand-new manager over the same stored bytes. // // Usage: bun tools/ark/manager-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 { hex } from '@scure/base'; import { ArkManager } from '../../src/ark/manager.js'; import { decodeVtxo } from '../../src/ark/proto.js'; import { validateVtxo } from '../../src/ark/validate.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 aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`); let ok = true; const check = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) ok = false; }; // localStorage-faithful storage: JSON round-trip on both directions const store = { raw: null }; const storage = { load: () => store.raw ? JSON.parse(store.raw) : null, save: (s) => { store.raw = JSON.stringify(s); }, }; // fresh wallet every run so the test is re-runnable const mnemonic = generateMnemonic(wordlist); const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'"); const newManager = () => new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init(); let mgr = await newManager(); // --- 1. receive with full validation --- console.log('\n[1] receive'); const addr = mgr.address(); sh(`${ALICE} -q send ${addr} "20000 sat"`); for (let i = 0; i < 10 && !mgr.balance().spendableSat; i++) { await mgr.sync(); await sleep(1500); } check('received 20000 sat', mgr.balance().spendableSat === 20000, JSON.stringify(mgr.balance())); check('movement logged', mgr.movements().some((m) => m.type === 'receive' && m.status === 'complete')); // tampered vtxo must fail validation const rec = mgr.vtxos()[0]; const tampered = hex.decode(rec.bytes); tampered[tampered.length - 100] ^= 0xff; // corrupt inside the genesis data let rejected = false; try { await validateVtxo(decodeVtxo(tampered), { serverPubkey: mgr.serverPub, chain: mgr.chain }); } catch { rejected = true; } check('tampered vtxo rejected by validator', rejected); // --- 2. plain send --- console.log('\n[2] send'); const aliceAddr = () => sh(`${ALICE} -q address`); let before = aliceBalance(); await mgr.send(aliceAddr(), 5000); check('alice +5000', aliceBalance() === before + 5000); check('change tracked', mgr.balance().spendableSat === 15000, JSON.stringify(mgr.balance())); // --- 3. crash mid-send: kill the network after cosign, resume from storage --- console.log('\n[3] crash mid-send + resume'); before = aliceBalance(); const realFetch = globalThis.fetch; let registerCalls = 0; globalThis.fetch = (url, opts) => { if (String(url).includes('RegisterVtxoTransactions') && ++registerCalls === 2) { // the register of the signed outputs — the state is already 'cosigned' return Promise.reject(new Error('simulated crash: network down')); } return realFetch(url, opts); }; let crashed = false; try { await mgr.send(aliceAddr(), 4000); } catch { crashed = true; } globalThis.fetch = realFetch; check('send crashed mid-flight', crashed); check('action persisted at cosigned', JSON.parse(store.raw).actions.some((a) => a.step === 'cosigned')); // "restart": brand-new manager over the same stored bytes mgr = await newManager(); await mgr.sync(); // resumes pending actions check('resumed send completed', mgr.pendingActions().length === 0); check('alice +4000 after resume', aliceBalance() === before + 4000); check('balance after resumed send', mgr.balance().spendableSat === 11000, JSON.stringify(mgr.balance())); // --- 4. board --- console.log('\n[4] board'); const { actionId, fundingAddress, feeSat } = await mgr.startBoard(30000); const raw = sh(`${BCLI} createrawtransaction '[]' '[{"${fundingAddress}":0.00030000}]'`); 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 mgr.completeBoard(actionId, fundingTxid); check('board waits for confirmations', mgr.pendingActions().length === 1); mine(2); for (let i = 0; i < 20 && mgr.pendingActions().length; i++) { await sleep(2000); await mgr.sync(); } check('board vtxo spendable', mgr.balance().spendableSat === 11000 + 30000 - feeSat, JSON.stringify(mgr.balance())); // --- 5. refresh: consolidate everything into one vtxo --- console.log('\n[5] refresh / consolidate'); const vtxosBefore = mgr.vtxos().filter((v) => v.state === 'spendable').length; await mgr.refresh(); for (let i = 0; i < 40 && mgr.pendingActions().length; i++) { await sleep(2000); mine(1); // keep confirming the round funding tx await mgr.sync(); } const spendable = mgr.vtxos().filter((v) => v.state === 'spendable'); check('refresh completed', mgr.pendingActions().length === 0); check(`consolidated ${vtxosBefore} vtxos into 1`, spendable.length === 1, `${spendable.length} spendable`); console.log(` balance after refresh: ${mgr.balance().spendableSat} sat`); // --- 6. spend the consolidated vtxo, then reload wallet from storage --- console.log('\n[6] spend consolidated + reload'); before = aliceBalance(); await mgr.send(aliceAddr(), 6000); check('alice +6000 from consolidated vtxo', aliceBalance() === before + 6000); const balBefore = mgr.balance(); mgr = await newManager(); // reload check('state survives reload', JSON.stringify(mgr.balance()) === JSON.stringify(balBefore), JSON.stringify(mgr.balance())); check('movement history intact', mgr.movements().length >= 5, `${mgr.movements().length} movements`); console.log(ok ? '\n✅ SUCCESS: ArkManager receive/send/crash-resume/board/refresh/reload all pass' : '\n❌ some checks failed'); process.exit(ok ? 0 : 1); 2'>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
DROP TABLE IF EXISTS `waiting_list`;
CREATE TABLE `waiting_list` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) DEFAULT NULL,
`phone` varchar(255) DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`status` enum('pending','contacted','registered', 'cancelled') DEFAULT NULL,
`notes` varchar(255),
`user_id` int(11),
KEY `user` (`user_id`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=latin1;
DROP TABLE IF EXISTS `referrals`;
CREATE TABLE `referrals` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`token` varchar(255) DEFAULT NULL,
`expiry` date DEFAULT NULL,
`status` enum('available', 'used', 'expired', 'cancelled') DEFAULT NULL,
`sponsor_id` int(11) NOT NULL,
`user_id` int(11) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
CONSTRAINT `sponsor` FOREIGN KEY (sponsor_id) REFERENCES users (`id`),
CONSTRAINT `sponsored` FOREIGN KEY (`user_id`) REFERENCES users (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=latin1;
ALTER TABLE invoices ADD CONSTRAINT `invoice_user` FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE invoices ADD CONSTRAINT `invoice_account` FOREIGN KEY (account_id) REFERENCES accounts (id);
ALTER TABLE orders ADD CONSTRAINT `order_user` FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE payments ADD CONSTRAINT `payment_user` FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE payments ADD CONSTRAINT `payment_account` FOREIGN KEY (account_id) REFERENCES accounts (id);
ALTER TABLE deposits ADD CONSTRAINT `deposit_user` FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE withdrawals ADD CONSTRAINT `withdrawal_user` FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE accounts ADD CONSTRAINT `account_user` FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE users ADD CONSTRAINT `user_account` FOREIGN KEY (account_id) REFERENCES accounts (id);
ALTER TABLE users ADD email varchar(255);
ALTER TABLE users ADD phone varchar(255);
ALTER TABLE users ADD admin tinyint default false;
DROP TABLE IF EXISTS `reset`;
DROP TABLE IF EXISTS `naughty`;
DROP TABLE IF EXISTS `cheaters`;
--
-- Table structure for table `migrations`
--
DROP TABLE IF EXISTS `migrations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `migrations` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`batch` int(11) DEFAULT NULL,
`migration_time` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `migrations_lock`
--
DROP TABLE IF EXISTS `migrations_lock`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `migrations_lock` (
`index` int(10) unsigned NOT NULL AUTO_INCREMENT,
`is_locked` int(11) DEFAULT NULL,
PRIMARY KEY (`index`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `urls`
--
DROP TABLE IF EXISTS `urls`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `urls` (
`hash` varchar(255) DEFAULT NULL,
`data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
UNIQUE KEY `urls_hash_unique` (`hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
---
--- New field changes...
---
UPDATE accounts set createdAt = '2020-01-01' where createdAt < '2020-01-01';
UPDATE accounts set updatedAt = '2020-01-01' where createdAt < '2020-01-01';
ALTER TABLE accounts modify `contract` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL;
ALTER TABLE invoices modify `tip` double NOT NULL DEFAULT 0;
ALTER TABLE invoices ADD `webhook` text DEFAULT NULL;
ALTER TABLE payments ADD `invoice_id` int(11) DEFAULT NULL;
ALTER TABLE payments modify `tip` double NOT NULL DEFAULT 0;
ALTER TABLE orders DROP a1;
ALTER TABLE orders DROP a2;
ALTER TABLE users MODIFY `unit` varchar(255) DEFAULT NULL;
ALTER TABLE users ADD `authyId` varchar(255) DEFAULT NULL;
ALTER TABLE users MODIFY `subscriptions` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL;
ALTER TABLE users DROP symbol;