aboutsummaryrefslogtreecommitdiffstats
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
parent62b6b80d9c8d03d6caadb0648059ba94e34f8b1d (diff)
downloadcoinosv3-08929aa42cdc74c3fe29007ceee3c2f8e06e6667.tar.gz
coinosv3-08929aa42cdc74c3fe29007ceee3c2f8e06e6667.zip
Add cooperative coin exits and clarify profile, preview and zap controls
-rw-r--r--src/app.js4
-rw-r--r--src/features/ark.js46
-rw-r--r--src/features/messages.js43
-rw-r--r--src/features/zaps.js8
-rw-r--r--src/i18n.js4
-rw-r--r--src/style.css13
-rw-r--r--tools/ark/cooperative-exit-ui-test.js159
-rw-r--r--tools/zap-feedback-test.js2
8 files changed, 245 insertions, 34 deletions
diff --git a/src/app.js b/src/app.js
index 2d9611a..b2584c2 100644
--- a/src/app.js
+++ b/src/app.js
@@ -5723,8 +5723,8 @@ const ctx = {
// as money landing: without this the balance counts up from 0 in green,
// which is the celebration a received payment gets.
forgetAmountAnim: () => _amtLast.clear(),
- // the one-tap zap amount, synced across devices with the rest of the state
- zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat || 0) : 0),
+ // Start at 21 sats; an explicitly saved preference wins across devices.
+ zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat ?? 21) : 21),
setZapDefaultSat: (n) => {
const p = wallet.loadFeatureState('prefs', {});
p.zapSat = Math.max(1, Math.floor(n) || 0);
diff --git a/src/features/ark.js b/src/features/ark.js
index 8fe21cc..3577242 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -733,6 +733,12 @@ export function arkFeature(ctx) {
return { spendableSat: sum('spendable'), pendingSat: sum('pending'), boardingSat };
}
+ function renewingSat() {
+ const s = arkStateNow();
+ const ids = new Set((s?.actions || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step)).flatMap((a) => a.inputIds || []));
+ return (s?.vtxos || []).filter((v) => v.state === 'pending' && ids.has(v.id)).reduce((n, v) => n + v.amountSat, 0);
+ }
+
// An ark address in the send form signals a send is coming: verify our
// spendable vtxos against the server now, so a stale one (same seed active
// elsewhere, restored state) is dropped before coin selection instead of
@@ -1954,7 +1960,7 @@ export function arkFeature(ctx) {
? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
: null,
payable && spendable < 330
- ? h('div', { class: 'notice info' }, t('zapNoBalance'))
+ ? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance'))
: payable
? amountInputs(t('arkZapHint'))
: null,
@@ -2203,6 +2209,7 @@ export function arkFeature(ctx) {
if (!mgr) connectArk().then(() => render()).catch(() => {});
const totalSat = spend.reduce((n, v) => n + v.amountSat, 0);
const heldSat = totalSat + pendingCoins.reduce((n, v) => n + v.amountSat, 0);
+ const spendableSat = arkBalance()?.spendableSat || 0;
let exitFee = 0; try { exitFee = estimateExitFeeSat(mgr); } catch {}
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
const afterFee = Math.ceil(530 * feeRate);
@@ -2390,6 +2397,17 @@ export function arkFeature(ctx) {
? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
: selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
h('div', { class: 'card col', style: 'gap:8px' },
+ h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
+ h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !spendableSat || wallet.watchOnly, onClick: () => {
+ const address = wallet.freshReceive().address;
+ ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null;
+ ui.arkOffboarded = null; ui.arkError = '';
+ ctx.showSend({ fresh: true });
+ ui.arkOffboardSend = { address, amount: String(spendableSat), fromCoins: true };
+ render();
+ } }, t('arkOffboardBtn', { n: fmtAmount(spendableSat) + ' ' + unitLabel() }))),
+ h('div', { class: 'card col', style: 'gap:8px' },
h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')),
h('p', { class: 'small muted', style: 'margin:0' },
t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })),
@@ -2889,8 +2907,11 @@ export function arkFeature(ctx) {
const o = ui.arkOffboardSend;
const spendable = arkBalance()?.spendableSat || 0;
return h('div', { class: 'card col', style: 'gap:12px' },
- h('h3', {}, t('arkExitSendTitle')),
- h('div', { class: 'small muted break' }, o.address),
+ h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')),
+ h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('recipient')),
+ h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none',
+ onInput: (e) => { o.address = e.target.value.trim(); } })),
h('div', { class: 'small faint' }, t('arkExitSendNote')),
h('div', { class: 'input-group' },
h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
@@ -2898,14 +2919,19 @@ export function arkFeature(ctx) {
h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
h('div', { class: 'row gap6' },
- h('button', { class: 'btn-ghost grow', onClick: () => { ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend(); render(); } }, t('back')),
+ h('button', { class: 'btn-ghost grow', onClick: () => {
+ ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend();
+ if (o.fromCoins) ctx.goBack(() => { ui.arkCoinsPage = true; }); else render();
+ } }, t('back')),
ui.arkBusy === 'offboard'
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' }))
: h('button', { class: 'btn-primary grow', onClick: () => {
- const sats = parseInt(o.amount, 10) || spendable;
- if (!sats || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; }
+ const amount = String(o.amount ?? '').trim();
+ const sats = amount ? Number(amount) : spendable;
+ if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; }
+ try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; }
doArkOffboard(sats >= spendable ? 0 : sats, o.address);
- } }, t('send'))),
+ } }, t('arkSendBtn'))),
// This send IS the cooperative exit. The trustless fallback only
// appears when cooperation actually failed — and it states, before
// anything is confirmed, exactly what it does and what the mining
@@ -2942,6 +2968,8 @@ export function arkFeature(ctx) {
async function doArkOffboard(amountSat, destAddress = null) {
ui.arkBusy = 'offboard'; ui.arkError = ''; render();
try {
+ const address = destAddress || wallet.freshReceive().address;
+ const spk = addrScript(address);
const mgr = await connectArk();
const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable');
const total = spendables().reduce((n, v) => n + v.amountSat, 0);
@@ -2960,11 +2988,10 @@ export function arkFeature(ctx) {
}
ids = [target.id];
}
- const address = destAddress || wallet.freshReceive().address;
- const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address));
const action = await mgr.startOffboard(spk, address, ids);
ui.arkOffboardAmt = '';
ui.arkMoveOpen = false;
+ if (ui.arkOffboardSend?.fromCoins) ui.tab = 'receive';
ui.arkOffboardSend = null;
ui.send = blankSend();
ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat };
@@ -3515,6 +3542,7 @@ export function arkFeature(ctx) {
return mgr.fetchBolt12(offerStr, amountSat);
},
lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; },
+ spendingRenewingSat() { return renewingSat(); },
// Max for any Lightning amount form: spendable minus estimated fees.
lnMaxSendSat() { return lnMaxSat(); },
settingsCards() { return [autoWithdrawCard()]; },
diff --git a/src/features/messages.js b/src/features/messages.js
index 54cbfa3..38fd1f6 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -7395,7 +7395,7 @@ export function messagesFeature(ctx) {
}
},
}),
- h('div', { class: 'row reply-tools', style: 'gap:8px;align-items:center' },
+ h('div', { class: 'row reply-tools wrap', style: 'gap:8px;align-items:center' },
// A reply can carry a picture too — same upload, same imeta tag, same
// paperclip. The URL lands in the draft, which is what every client
// reads as the media.
@@ -7416,12 +7416,13 @@ export function messagesFeature(ctx) {
});
},
}),
- previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('span', { class: 'grow' }),
- h('button', {
- class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; render(); },
- }, t('cancel')),
+ h('div', { class: 'row gap6 composer-options' },
+ previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
+ h('button', {
+ class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
+ onClick: () => { s.replying = false; s.preview = false; render(); },
+ }, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
onClick: async () => {
@@ -7797,13 +7798,18 @@ export function messagesFeature(ctx) {
? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap;overflow-wrap:anywhere' },
...noteBody(about.slice(0, 1000)))
: null,
- // the npub, tap to copy — on every profile including your own; it
- // only steps aside while the (long) edit form is open
+ // A readable public-ID preview; copying always uses the full npub.
+ // It only steps aside while the (long) edit form is open.
ui.profEdit ? null : h('button', {
- class: 'addr-box break npub-box', title: t('copy'),
- style: 'font-size:11px;cursor:pointer;text-align:left;width:100%',
+ type: 'button', class: 'profile-key', title: npub,
+ 'aria-label': t('copy') + ': ' + t('profPublicKey'),
onClick: async () => { try { await navigator.clipboard.writeText(npub); toast(t('copied')); } catch {} },
- }, npub),
+ },
+ h('span', { class: 'profile-key-content' },
+ h('span', { class: 'profile-key-label' }, t('profPublicKey')),
+ h('bdi', { class: 'profile-key-value', dir: 'ltr' }, npub.slice(0, 12) + '…' + npub.slice(-8))),
+ h('span', { class: 'profile-key-copy', 'aria-hidden': 'true', html:
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="3"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>' })),
ui.profEdit
? h('div', { class: 'col', style: 'gap:8px' },
// the username IS the payment address and NIP-05 — the frozen
@@ -8059,7 +8065,6 @@ export function messagesFeature(ctx) {
h('button', { class: 'btn-danger grow', onClick: cancelPost }, t('postDiscard')))));
}
const CLIP = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
- const EYE = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
// ---- draft preview --------------------------------------------------------
// A post is written as plain text, but it doesn't ARRIVE as plain text: a
@@ -8087,11 +8092,12 @@ export function messagesFeature(ctx) {
h('div', { class: 'media-progress-track' }, h('div', { class: 'media-progress-fill', style: 'width:' + m.pct + '%' })));
}
const previewBtn = (on, toggle) => h('button', {
- class: 'attach-btn' + (on ? ' on' : ''),
+ type: 'button', class: 'btn-ghost btn-sm compose-preview-toggle',
title: on ? t('composePreviewHide') : t('composePreview'),
- 'aria-label': on ? t('composePreviewHide') : t('composePreview'),
+ 'aria-label': t('composePreview'),
+ 'aria-pressed': String(on),
onClick: toggle,
- }, h('span', { style: 'display:flex', html: EYE }));
+ }, t('composePreview'));
function postComposer() {
if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null;
const text = composeText();
@@ -8113,7 +8119,7 @@ export function messagesFeature(ctx) {
}),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
- h('div', { class: 'row gap6' },
+ h('div', { class: 'row gap6 wrap' },
h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
const body = composeText().trim();
if (!body) return;
@@ -8136,12 +8142,13 @@ export function messagesFeature(ctx) {
class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
- previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }),
h('input', {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
- h('button', { class: 'btn-ghost', onClick: requestCancelPost }, t('cancel'))),
+ h('div', { class: 'row gap6 composer-options' },
+ previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }),
+ h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))),
postCancelWarning());
}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index 7a5f335..a99de94 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -338,7 +338,7 @@ export function zapsFeature(ctx) {
// Spending can't even cover their minimum, say so instead of rendering
// an impossible range.
const p = z.params;
- const ours = hook('lnSpendableSat');
+ const ours = hook('lnSpendableSat') ?? (hook('arkReady') ? 0 : null);
const min = Math.ceil(p.minSendable / 1000);
const max = Math.min(Math.floor(p.maxSendable / 1000), ours != null ? ours : Infinity);
const broke = ours != null && (ours <= 0 || max < min);
@@ -349,7 +349,9 @@ export function zapsFeature(ctx) {
const typedSats = parseAmount(z.amount, getUnit()) || 0;
const theirMaxSat = Math.floor(p.maxSendable / 1000);
const boardable = !!hook('arkReady') && ours != null;
- const needsBoard = boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat));
+ const renewing = hook('spendingRenewingSat') || 0;
+ const waitingOnRenewal = renewing > 0 && broke && ours + renewing >= Math.max(min, p.fixedSat || typedSats);
+ const needsBoard = !waitingOnRenewal && boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat));
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, heading),
(z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || z.address || ''),
@@ -367,7 +369,7 @@ export function zapsFeature(ctx) {
: null,
h('div', { style: 'display:flex;align-items:center' }, unitTag())),
broke
- ? h('div', { class: 'notice info' }, t('zapNoBalance'))
+ ? h('div', { class: 'notice info' }, t(waitingOnRenewal ? 'spendingRenewingPayment' : 'zapNoBalance'))
: p.fixedSat
? h('div', { class: 'small faint' }, t('clinkFixedAmount'))
: h('div', { class: 'small faint' }, `Min ${min.toLocaleString()} · max ${max.toLocaleString()} sats`),
diff --git a/src/i18n.js b/src/i18n.js
index 8d67b3a..38d23c7 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -103,6 +103,7 @@ const STR = {
profUsername: 'Username',
profUploadPic: 'Upload photo',
profDisplayName: 'Display name',
+ profPublicKey: 'Nostr public key',
profFetchFailed: 'Couldn’t load the latest profile.',
profNotFound: 'No profile details found on the available relays.',
profUnameInvalid: 'Usernames can only use letters, numbers, dots, dashes and underscores.',
@@ -445,6 +446,7 @@ const STR = {
msgNpubPlaceholder: 'npub…',
msgSearchPlaceholder: 'Name or npub…',
zapNoBalance: "There's not enough in Spending to send a zap. Move some money into Spending first.",
+ spendingRenewingPayment: 'Your Spending coins are renewing. You can pay once the renewal finishes.',
zapBoardBtn: 'Move sats into Spending',
onbBoardTitle: 'Top up Spending',
onbBoardBody: 'Move some sats from Savings into Spending. Payments there are instant, with no mining fees.',
@@ -492,6 +494,8 @@ const STR = {
arkRenewingHistory: 'Renewing',
arkRenewingOpen: 'See the coins',
arkCoinsExitTitle: 'Unilateral exit',
+ arkCoinsCoopTitle: 'Co-operative exit',
+ arkOffboardBadAddress: 'Enter a valid on-chain address for this network.',
arkCoinsExitBtn: 'Exit now',
arkExitConfirmTitle: 'Start a unilateral exit?',
arkExitConfirmBody: 'This brings your Spending coins to Savings on-chain without the server, by publishing each coin’s transaction history. Nothing needs the server’s cooperation (that is the point), but it is slow and it costs mining fees.',
diff --git a/src/style.css b/src/style.css
index f77c7d9..c7aec59 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1046,7 +1046,18 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
box-shadow: 0 0 0 2px var(--bg);
}
.chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; }
-.npub-box { display: flex; align-items: center; gap: 8px; }
+.profile-key {
+ display: flex; align-items: center; gap: 16px; width: 100%; padding: 14px 16px;
+ border: 0; border-radius: 12px; background: var(--surface2);
+ font-family: var(--sans); text-align: start;
+}
+.profile-key:hover { background: var(--accent-soft); }
+.profile-key-content { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
+.profile-key-label { color: var(--muted); font-size: 12px; font-weight: 500; }
+.profile-key-value { font-size: 14px; font-weight: 400; overflow-wrap: anywhere; }
+.profile-key-copy { display: flex; flex-shrink: 0; color: var(--muted); }
+.composer-options { margin-inline-start: auto; flex-shrink: 0; }
+.compose-preview-toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); }
.copy-ico { flex-shrink: 0; background: none; border: none; padding: 2px; color: var(--muted); cursor: pointer; display: flex; }
.copy-ico:hover { color: var(--ink); }
.settings-nav { text-align: left; font-weight: 600; padding: 14px 16px; position: relative; }
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' } }) });