aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 17:07:21 -0700
committerAdam Soltys <[email protected]>2026-10-01 17:07:21 -0700
commit08929aa42cdc74c3fe29007ceee3c2f8e06e6667 (patch)
tree5f5f0140cd420b4955ac115f6cffd488bee4a719 /tools
parent62b6b80d9c8d03d6caadb0648059ba94e34f8b1d (diff)
downloadcoinosv3-08929aa42cdc74c3fe29007ceee3c2f8e06e6667.tar.gz
coinosv3-08929aa42cdc74c3fe29007ceee3c2f8e06e6667.zip
Add cooperative coin exits and clarify profile, preview and zap controls
Diffstat (limited to 'tools')
-rw-r--r--tools/ark/cooperative-exit-ui-test.js159
-rw-r--r--tools/zap-feedback-test.js2
2 files changed, 160 insertions, 1 deletions
diff --git a/tools/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js
new file mode 100644
index 0000000..1af394e
--- /dev/null
+++ b/tools/ark/cooperative-exit-ui-test.js
@@ -0,0 +1,159 @@
+// Real app navigation/forms with a local Ark manager stub; never transfers funds.
+// Run: bun tools/ark/cooperative-exit-ui-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'cooperative-exit-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, offboard: doArkOffboard };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'off');
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ window.coopCalls = [];
+ window.coopManager = {
+ state: { vtxos: [{ id: 'coin-a', amountSat: 12000, state: 'spendable' }, { id: 'coin-b', amountSat: 8000, state: 'spendable' }], actions: [], movements: [] },
+ info: { network: 'regtest' }, _tipH: 100,
+ vtxos() { return this.state.vtxos; },
+ _decoded() { throw new Error('no exit transaction fixture'); },
+ refreshFee() { return 0; }, unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ async send(address, amount) { coopCalls.push({ type: 'split', address, amount }); throw new Error('unexpected split'); },
+ async startOffboard(spk, address, ids) {
+ coopCalls.push({ type: 'offboard', address, ids: ids || null, script: [...spk] });
+ return { txid: '1'.repeat(64), netSat: 19900, feeSat: 100 };
+ },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.ui.screen = 'wallet'; coopApp.ui.tab = 'receive'; coopApp.render();
+ }, generateMnemonic(wordlist));
+ const manage = async () => {
+ await page.evaluate(() => {
+ const { ui, render } = coopApp;
+ ui.arkOffboarded = null; ui.arkBusy = null; ui.arkError = ''; ui.arkCoinsPage = true;
+ render();
+ });
+ await page.waitForSelector('.coin-table');
+ };
+ const coopButton = () => page.evaluate(() => [...document.querySelectorAll('.card')]
+ .find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').click());
+ const send = () => page.evaluate(() => [...document.querySelectorAll('.card')]
+ .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click());
+ const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value);
+ await manage();
+ assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']);
+ await coopButton();
+ await page.waitForSelector('input.mono-input');
+ const savings = await page.evaluate(() => coopApp.wallet.freshReceive().address);
+ assert.equal(await page.$eval('input.mono-input', (e) => e.value), savings);
+ assert.equal(await page.$eval('input[type="number"]', (e) => e.value), '20000');
+ assert.equal(await page.$eval('.card .btn-primary', (e) => e.textContent), 'Send');
+ assert.deepEqual(await page.evaluate(() => coopCalls), [], 'opening the form never starts an exit');
+ await page.evaluate(() => [...document.querySelectorAll('.card button')].find((b) => b.textContent.includes('Back')).click());
+ await page.waitForSelector('.coin-table');
+ assert.equal(await page.evaluate(() => coopApp.ui.arkOffboardSend), null);
+ console.log('✓ Manage offers a co-operative exit above unilateral exit; Savings and the full balance are prefilled; Back returns to Manage');
+
+ await coopButton();
+ for (const address of ['not-an-address', '']) {
+ await edit('input.mono-input', address);
+ await send();
+ assert.match(await page.$eval('.notice.err', (e) => e.textContent), /valid on-chain address/);
+ }
+ await edit('input.mono-input', savings);
+ for (const amount of ['0', '-1', '1.5', '20001']) {
+ await edit('input[type="number"]', amount); await send();
+ assert.deepEqual(await page.evaluate(() => coopCalls), [], 'invalid inputs must not split or offboard funds');
+ }
+ await page.evaluate(() => coopFeature.offboard(1000, 'not-an-address'));
+ assert(await page.evaluate(() => !!coopApp.ui.arkError), 'internal offboarding also rejects an invalid destination');
+ assert.deepEqual(await page.evaluate(() => coopCalls), []);
+ console.log('✓ Invalid destinations, zero, fractional and excessive amounts cannot initiate an exit or coin split');
+
+ await edit('input.mono-input', savings);
+ await edit('input[type="number"]', '20000'); await send();
+ await page.waitForFunction(() => coopApp.ui.arkOffboarded && !coopApp.ui.arkBusy);
+ const all = await page.evaluate(() => coopCalls);
+ assert.equal(all.length, 1); assert.equal(all[0].type, 'offboard'); assert.equal(all[0].address, savings); assert.equal(all[0].ids, null);
+ assert.deepEqual(all[0].script, await page.evaluate(() => [...coopApp.wallet.freshReceive().script]));
+ assert.equal(await page.evaluate(() => coopApp.ui.tab), 'receive');
+ assert.match(await page.$eval('.card h2', (e) => e.textContent), /On its way/);
+ console.log('✓ Sending the prefilled balance offboards every spendable coin to Savings and displays the result');
+
+ await manage(); await coopButton();
+ const other = await page.evaluate(() => coopApp.wallet.derive(0, 1).address);
+ await edit('input.mono-input', other);
+ await edit('input[type="number"]', '12000'); await send();
+ await page.waitForFunction(() => coopCalls.length === 2 && !coopApp.ui.arkBusy);
+ assert.deepEqual(await page.evaluate(() => ({ address: coopCalls[1].address, ids: coopCalls[1].ids })), { address: other, ids: ['coin-a'] });
+ assert.deepEqual(await page.evaluate(() => coopCalls[1].script), await page.evaluate(() => [...coopApp.wallet.derive(0, 1).script]));
+ console.log('✓ Editing the destination and amount offboards only the requested amount to the chosen on-chain address');
+
+ await manage(); await coopButton();
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Wallet"]').click());
+ assert.equal(await page.evaluate(() => coopApp.ui.arkOffboardSend), null);
+ await manage();
+ await page.evaluate(() => { coopManager.state.vtxos.forEach((v) => v.state = 'pending'); coopManager.state.actions = [{ type: 'refresh', step: 'submitted', inputIds: ['coin-a', 'coin-b'], inAmountSat: 20000 }]; coopApp.render(); });
+ assert(await page.evaluate(() => [...document.querySelectorAll('.card')].find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').disabled));
+ await page.evaluate(() => {
+ const { ui, render } = coopApp;
+ ui.arkCoinsPage = null; ui.tab = 'send';
+ ui.arkZap = { status: 'ready', pk: '2'.repeat(64), npub: 'recipient', address: 'test', amount: '21', comment: '' };
+ render();
+ });
+ assert.match(await page.$eval('.card .notice.info', (e) => e.textContent), /coins are renewing/);
+ await page.evaluate(() => { coopManager.state.actions[0].step = 'done'; coopApp.render(); });
+ assert.match(await page.$eval('.card .notice.info', (e) => e.textContent), /not enough in Spending/);
+ await page.evaluate(() => { coopManager.state.actions[0].step = 'submitted'; coopManager.state.actions[0].type = 'offboard'; coopApp.render(); });
+ assert.match(await page.$eval('.card .notice.info', (e) => e.textContent), /not enough in Spending/);
+ await page.evaluate(() => {
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+ coopManager.state.actions[0].type = 'refresh';
+ coopApp.ui.arkZap = null;
+ coopApp.ui.zap = { status: 'ready', social: true, target: { pk: '2'.repeat(64) }, amount: '21', comment: '', params: { minSendable: 1000, maxSendable: 500000000 } };
+ coopApp.render();
+ });
+ assert.match(await page.$eval('.card .notice.info', (e) => e.textContent), /coins are renewing/);
+ assert(await page.$eval('.card .btn-primary', (e) => e.disabled), 'pending renewal funds cannot be paid');
+ await page.evaluate(() => { coopManager.state.vtxos.forEach((v) => v.state = 'spendable'); coopManager.state.actions[0].step = 'done'; coopApp.render(); });
+ assert.equal(await page.$('.card .notice.info'), null, 'the payment form recovers when the renewal settles');
+ assert.equal(await page.$eval('.card .btn-primary', (e) => e.disabled), false);
+ console.log('✓ Ark and Lightning forms identify renewal funds without mislabeling other pending funds; they recover once spendable');
+
+ assert.equal(await page.evaluate(() => coopApp.ctx.zapDefaultSat()), 21, 'fresh wallets start with a 21-sat zap preference');
+ await page.evaluate(() => coopApp.ctx.setZapDefaultSat(73));
+ assert.equal(await page.evaluate(() => coopApp.ctx.zapDefaultSat()), 73, 'a saved preference overrides the default');
+ console.log('✓ New users default to 21-sat zaps and explicit preferences are preserved');
+ assert.deepEqual(errors, []);
+ console.log('✓ Leaving the form clears it; coins already in a renewal cannot be exited again; no browser errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/zap-feedback-test.js b/tools/zap-feedback-test.js
index 21e04cf..838aef4 100644
--- a/tools/zap-feedback-test.js
+++ b/tools/zap-feedback-test.js
@@ -77,7 +77,7 @@ const bundle = await Bun.build({
assert(bundle.success, bundle.logs.join('\n'));
const css = await Bun.file('src/style.css').text();
const js = await bundle.outputs[0].text();
-const html = `<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}
body { padding: 110px 16px; } article { max-width: 480px; margin: auto; padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; } #actions { margin-top: 30px; }
</style><article data-zap-post="${'3'.repeat(64)}"><strong>@coinos</strong><p>A little lightning goes a long way.</p><div id="actions"></div></article><div id="setup"></div><script type="module">${js}</script>`;
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });