aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-03 15:08:26 -0700
committerAdam Soltys <[email protected]>2026-07-03 15:08:26 -0700
commit190c13a338462a7a76ee65e9486e42c0165a65f4 (patch)
tree0006e713e91e39b5b90d458a35b6c4a33cef34cc /tools/ark
parent5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9 (diff)
downloadcoinosv3-190c13a338462a7a76ee65e9486e42c0165a65f4.tar.gz
coinosv3-190c13a338462a7a76ee65e9486e42c0165a65f4.zip
Ark: wire into the wallet UI
- Settings: Ark server card (off by default, per-network presets incl. Second mainnet + local regtest, custom URLs) with balance, coin count, board form (on-chain -> Ark; funding output pinned to vout 0 via the new buildTx noSort option), refresh/consolidate, and recent activity. - Receive: Ark option in the address-type dropdown (QR + copy). - Send: t?ark1 addresses are recognized in the normal send form and route to an Ark confirmation (no mining fee) with a success view. - Balance card: Ark balance line under the on-chain figure. - ArkManager lifecycle: per-wallet instance created on account activation, background sync every 5s (regtest) / 30s, state persisted under the wallet cache key. Verified end-to-end in a real browser (tools/ark/ui-test.js, puppeteer against the regtest stack): receive, send, board, and refresh all green. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'tools/ark')
-rw-r--r--tools/ark/ui-shots.js61
-rw-r--r--tools/ark/ui-test.js172
2 files changed, 233 insertions, 0 deletions
diff --git a/tools/ark/ui-shots.js b/tools/ark/ui-shots.js
new file mode 100644
index 0000000..5331ab8
--- /dev/null
+++ b/tools/ark/ui-shots.js
@@ -0,0 +1,61 @@
+// Screenshot run: import a wallet, receive over Ark, show the balance card +
+// Ark settings card. Writes PNGs to /tmp/ark-ui-*.png.
+import { spawn, execSync } from 'node:child_process';
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const PORT = 5199;
+const APP = `http://localhost:${PORT}`;
+const BARK = `${process.env.HOME}/bark/target/debug/bark`;
+const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+
+const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' });
+for (let i = 0; i < 30; i++) { try { if ((await fetch(APP)).ok) break; } catch {} await sleep(500); }
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 420, height: 860, deviceScaleFactor: 2 });
+const clickText = (sel, text) => page.evaluate((sel, text) => {
+ const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().toLowerCase().includes(text.toLowerCase()));
+ if (el) el.click();
+}, sel, text);
+
+await page.goto(APP, { waitUntil: 'domcontentloaded' });
+await page.evaluate(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' }));
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+});
+await page.reload({ waitUntil: 'domcontentloaded' });
+await sleep(500);
+await clickText('button', 'Import existing');
+await sleep(200);
+await page.type('textarea', generateMnemonic(wordlist));
+await clickText('button', 'Open wallet');
+await sleep(4000);
+
+await page.select('select', 'ark');
+await sleep(400);
+const addr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim());
+await page.screenshot({ path: '/tmp/ark-ui-receive.png' });
+
+sh(`${ALICE} -q send ${addr} "25000 sat"`);
+for (let i = 0; i < 30; i++) { await sleep(1000); if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes('ark balance')) break; }
+await sleep(500);
+await page.screenshot({ path: '/tmp/ark-ui-balance.png' });
+
+await clickText('.tabs button', 'Settings');
+await sleep(500);
+await page.evaluate(() => {
+ const el = [...document.querySelectorAll('h3')].find((e) => e.textContent.includes('Ark'));
+ if (el) el.scrollIntoView({ block: 'start' });
+});
+await sleep(300);
+await page.screenshot({ path: '/tmp/ark-ui-settings.png' });
+
+await browser.close();
+dev.kill();
+console.log('done');
diff --git a/tools/ark/ui-test.js b/tools/ark/ui-test.js
new file mode 100644
index 0000000..f8288e2
--- /dev/null
+++ b/tools/ark/ui-test.js
@@ -0,0 +1,172 @@
+// Browser-level e2e of the Ark UI wiring, driven with puppeteer against the
+// real dev server + the ark regtest stack:
+//
+// import wallet -> ark connects -> receive over Ark (alice pays the address
+// shown in the Receive tab) -> send over Ark from the Send form -> board
+// from the Settings card -> refresh/consolidate -> balances line up.
+//
+// Usage: bun tools/ark/ui-test.js (regtest stack up + nothing on :5199)
+
+import { spawn, execSync } from 'node:child_process';
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const PORT = 5199;
+const APP = `http://localhost:${PORT}`;
+const BARK = `${process.env.HOME}/bark/target/debug/bark`;
+const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
+const BCLI = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`;
+
+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 aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat;
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// --- dev server ---
+const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' });
+for (let i = 0; i < 30; i++) {
+ try { if ((await fetch(APP)).ok) break; } catch {}
+ await sleep(500);
+}
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
+
+// helpers — NB innerText respects CSS text-transform, so match case-insensitively
+const bodyText = () => page.evaluate(() => document.body.innerText);
+const clickText = async (sel, text) => {
+ const found = await page.evaluate((sel, text) => {
+ const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().toLowerCase().includes(text.toLowerCase()));
+ if (el) { el.click(); return true; }
+ return false;
+ }, sel, text);
+ if (!found) throw new Error(`clickText: no ${sel} with "${text}"`);
+};
+const waitText = async (text, ms = 20000) => {
+ for (let i = 0; i < ms / 250; i++) {
+ if ((await bodyText()).toLowerCase().includes(text.toLowerCase())) return true;
+ await sleep(250);
+ }
+ return false;
+};
+// The number shown next to a label like "ark balance" (sats mode, comma groups)
+const labeledNumber = async (label) => {
+ const txt = await bodyText();
+ const esc = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const m = txt.match(new RegExp(esc + String.raw`[\s\n]*([\d,]+)`, 'i'));
+ return m ? Number(m[1].replaceAll(',', '')) : null;
+};
+const waitLabeledNumber = async (label, want, ms = 30000) => {
+ for (let i = 0; i < ms / 500; i++) {
+ if (await labeledNumber(label) === want) return true;
+ await sleep(500);
+ }
+ return false;
+};
+
+try {
+ // --- configure: regtest, esplora on the ark chain, ark local ---
+ await page.goto(APP, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' }));
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(500);
+
+ // --- import a fresh wallet ---
+ console.log('\n[1] import wallet + ark connects');
+ const mnemonic = generateMnemonic(wordlist);
+ await clickText('button', 'Import existing');
+ await sleep(200);
+ await page.type('textarea', mnemonic);
+ await clickText('button', 'Open wallet');
+ check('wallet opened', await waitText('receive', 15000));
+
+ check('ark connected (receive option present)', await (async () => {
+ for (let i = 0; i < 40; i++) {
+ const has = await page.evaluate(() => [...document.querySelectorAll('option')].some((o) => o.value === 'ark'));
+ if (has) return true;
+ await sleep(500);
+ }
+ return false;
+ })());
+
+ // --- receive over ark ---
+ console.log('\n[2] receive over Ark');
+ await page.select('select', 'ark');
+ await sleep(300);
+ const arkAddr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim() || '');
+ check('ark address shown', /^tark1[a-z0-9]+$/.test(arkAddr), arkAddr.slice(0, 24) + '…');
+ sh(`${ALICE} -q send ${arkAddr} "25000 sat"`);
+ check('ark balance shows 25,000', await waitLabeledNumber('ark balance', 25000, 30000), String(await labeledNumber('ark balance')));
+
+ // --- send over ark ---
+ console.log('\n[3] send over Ark');
+ const aliceAddr = sh(`${ALICE} -q address`);
+ const before = aliceBalance();
+ await clickText('.tabs button', 'Send');
+ await sleep(300);
+ await page.type('.mono-input', aliceAddr);
+ await sleep(400); // destReady re-render reveals the amount input
+ await page.type('input[type="number"]', '7000');
+ await clickText('button.btn-primary', 'Review transaction');
+ check('ark review shown', await waitText('Send over Ark', 5000));
+ await clickText('button.btn-primary', 'Send');
+ check('sent view', await waitText('Sent!', 15000));
+ check('alice +7000', aliceBalance() === before + 7000, String(aliceBalance() - before));
+
+ // --- board from settings ---
+ console.log('\n[4] board');
+ await clickText('.tabs button', 'Receive');
+ await sleep(300);
+ await page.select('select', 'address');
+ await sleep(300);
+ const onchainAddr = await page.evaluate(() => document.querySelector('.addr-box').textContent.trim());
+ sh(`${BCLI} sendtoaddress ${onchainAddr} 0.001`);
+ mine(1);
+ check('onchain funds arrive', await waitText('100,000', 30000));
+
+ await clickText('.tabs button', 'Settings');
+ await sleep(300);
+ const preBoard = await labeledNumber('ark balance');
+ const boardInput = await page.evaluateHandle(() =>
+ [...document.querySelectorAll('input')].find((i) => (i.placeholder || '').toLowerCase().startsWith('board amount')));
+ await boardInput.asElement().type('30000');
+ await clickText('button', 'Board');
+ check('board started', await waitText('Boarding', 20000));
+ mine(2);
+ check('board vtxo lands (+29,670)', await waitLabeledNumber('ark balance', preBoard + 29670, 40000),
+ String(await labeledNumber('ark balance')));
+
+ // --- refresh / consolidate ---
+ console.log('\n[5] refresh');
+ await clickText('button', 'Refresh / consolidate');
+ check('refresh submitted', await waitText('Refresh submitted', 10000));
+ const done = await (async () => {
+ for (let i = 0; i < 45; i++) {
+ mine(1);
+ await sleep(2000);
+ if (await labeledNumber('coins (vtxos)') === 1 && !(await bodyText()).toLowerCase().includes('in progress')) return true;
+ }
+ return false;
+ })();
+ check('refresh consolidated to 1 coin', done, String(await labeledNumber('coins (vtxos)')));
+ console.log(` final ark balance: ${await labeledNumber('ark balance')} sats`);
+
+ console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board, refresh all work in the browser'
+ : '\n❌ some checks failed');
+} finally {
+ await browser.close();
+ dev.kill();
+}
+process.exit(ok ? 0 : 1);