diff options
| -rw-r--r-- | src/features/ark.js | 105 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | tools/ark/cooperative-exit-ui-test.js | 14 | ||||
| -rw-r--r-- | tools/ark/custody-ui-test.js | 19 |
4 files changed, 76 insertions, 68 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 3e44805..3c23766 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2454,6 +2454,12 @@ export function arkFeature(ctx) { for (const id of ui.arkCoinsSel) if (!ids.has(id)) ui.arkCoinsSel.delete(id); const sel = ui.arkCoinsSel; const selCoins = spend.filter((v) => sel.has(v.id)); + // the three actions below the table act on the ticked coins + const selExitable = selCoins.filter((v) => exitable.includes(v)); + const selExitSat = selExitable.reduce((n, v) => n + v.amountSat, 0); + let selExitFee = 0; try { if (mgr) selExitFee = estimateExitFeeSat(mgr, selExitable); } catch {} + const subset = selCoins.length > 0 && selCoins.length < spend.length; + const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat; let selFee = 0; try { selFee = mgr.refreshFee(selCoins, tip); } catch {} const renew = () => { if (!selCoins.length || !mgr) return; @@ -2464,6 +2470,20 @@ export function arkFeature(ctx) { ui.arkRenewConfirm = null; render(); // the page stays: the coins show as renewing }; + // What a renewal is and what it costs, told where it is decided: on the + // confirmation, not as a card on the page (the page is the coins and + // their three buttons; the explainers were a wall under them). + const renewExplainer = () => [ + h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors + ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') }) + : baseSat + ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost }) + : t('arkCoinsRenewDesc', { every: roundEvery })), + schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' }, + ...schedule.map((r) => h('div', { class: 'row between' }, + h('span', { class: 'small muted' }, r.label), + h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null, + ]; // "Renew" asks once, with the numbers on the table: the coins it takes // can't be spent until the round's transaction confirms, it can't be // called back, and by default it takes every coin. (A user renewed their @@ -2476,6 +2496,7 @@ export function arkFeature(ctx) { h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('arkRenewConfirmTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmBody')), + ...renewExplainer(), h('div', { class: 'col', style: 'gap:4px' }, row(t('arkExitConfirmCoins'), String(selCoins.length)), row(t('arkExitConfirmAmount'), fmtSats(selSat) + ' sats'), @@ -2516,22 +2537,23 @@ export function arkFeature(ctx) { // is published: an exit cannot be called back. if (ui.arkExitConfirm) { const savings = wallet.spendable || 0; - const short = savings < exitFee; + const short = savings < selExitFee; const row = (k, v, cls = '') => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' + (cls ? ' ' + cls : '') }, v)); return h('div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(selExitFee), after: fmtSats(afterFee) })), h('div', { class: 'col', style: 'gap:4px' }, - row(t('arkExitConfirmCoins'), String(exitable.length)), - row(t('arkExitConfirmAmount'), fmtSats(exitable.reduce((n, v) => n + v.amountSat, 0)) + ' sats'), - row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'), + row(t('arkExitConfirmCoins'), String(selExitable.length)), + row(t('arkExitConfirmAmount'), fmtSats(selExitSat) + ' sats'), + row(t('arkExitConfirmFee'), fmtSats(selExitFee) + ' sats'), row(t('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')), - short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), have: fmtSats(savings) })) : null, + short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(selExitFee), have: fmtSats(savings) })) : null, h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmWait')), - h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !exitable.length, onClick: () => { + h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !selExitable.length, onClick: () => { ui.arkExitConfirm = null; - doArkExit(); + doArkExit(selExitable.map((v) => v.id)); ui.arkCoinsSel = null; back(); ui.tab = 'history'; // the exit lives as a pending record there @@ -2606,38 +2628,19 @@ export function arkFeature(ctx) { h('td', { class: 'num' }, fmtSats(v.amountSat), h('span', { class: 'small faint' }, ' sats')), h('td', { class: 'num' }, expiresOf(v)), h('td', { class: 'num' }, '—'), - h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing'))))))))), - renewingCard, - embedded ? custodyCard() : null, - h('div', { class: 'card col', style: 'gap:8px' }, - h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')), - // the round cadence comes from the server (it has been an hour and - // is two minutes as of September 2026) — never a number baked in here - h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors - ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') }) - : baseSat - ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost }) - : t('arkCoinsRenewDesc', { every: roundEvery })), - schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' }, - ...schedule.map((r) => h('div', { class: 'row between' }, - h('span', { class: 'small muted' }, r.label), - h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null, - !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } }, - !selCoins.length ? t('arkCoinsRenewNone') - : selCoins.length < spend.length - ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') }) - : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))), - // The ticked coins are what leaves: a subset goes on-chain as exactly - // those coins (no fresh coin split off the whole balance first), all - // of them — or nothing ticked — means the whole balance as before. - (() => { - const subset = selCoins.length > 0 && selCoins.length < spend.length; - const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat; - return 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')), - subset ? h('p', { class: 'small muted', style: 'margin:0' }, selCoins.length === 1 ? t('arkCoopSelectedOne') : t('arkCoopSelected', { n: selCoins.length })) : null, - h('button', { class: 'btn-primary btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || wallet.watchOnly, onClick: () => { + h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing')))))))), + // What the ticked coins can do, right under them: renew, withdraw + // (the co-operative exit, into Savings) or exit on their own. Each + // names the ticked total it acts on. + // present even while every coin is in a round: disabled, so the page + // keeps its shape and says why nothing can be done + h('div', { class: 'col ark-coin-actions', style: 'gap:6px;margin-top:4px' }, + h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } }, + !selCoins.length ? t('arkCoinsRenewNone') + : selCoins.length < spend.length + ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') }) + : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn')), + h('button', { class: 'btn-ghost btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || !selCoins.length || wallet.watchOnly, onClick: () => { const address = wallet.freshReceive().address; const vtxoIds = subset ? selCoins.map((v) => v.id) : undefined; ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null; @@ -2645,13 +2648,11 @@ export function arkFeature(ctx) { ctx.showSend({ fresh: true }); ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds }; render(); - } }, t('arkOffboardBtn', { n: fmtNative(coopSat) }))); - })(), - h('div', { class: 'card col', style: 'gap:8px' }, - h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, - exitable.length ? t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) }) : t('arkExitNoneExitable')), - h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !exitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))), + } }, t('arkCoinsWithdrawBtn', { amount: fmtNative(coopSat) })), + h('button', { class: 'btn-ghost btn-block ark-exit-ask', disabled: !!ui.arkBusy || !selExitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, + t('arkCoinsExitBtnAmt', { amount: fmtNative(selExitSat) })))), + renewingCard, + embedded ? custodyCard() : null, embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } @@ -2913,9 +2914,9 @@ export function arkFeature(ctx) { // Fee = size × rate needs none of it. Still memoized on the coin set + // feerate, since the advisory asks on every wallet render. let _exitFeeMemo = { key: null, val: 0 }; - function estimateExitFeeSat(mgr) { + function estimateExitFeeSat(mgr, only = null) { const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); - const coins = exitableCoins(mgr.state, mgr._tipH || 0); + const coins = only || exitableCoins(mgr.state, mgr._tipH || 0); const key = feeRate + '|' + coins.map((v) => v.id).join(','); if (_exitFeeMemo.key === key) return _exitFeeMemo.val; let total = 0; @@ -2930,16 +2931,17 @@ export function arkFeature(ctx) { return total; } - async function doArkExit() { + // ids: exactly these coins (the Manage page's ticked ones); else every coin + async function doArkExit(ids = null) { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { const mgr = await connectArk(); - const spendables = exitableCoins(mgr.state, mgr._tipH || 0); + const spendables = exitableCoins(mgr.state, mgr._tipH || 0).filter((v) => !ids || ids.includes(v.id)); if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected')); // Fees are paid hop by hop from Savings as the exit unrolls — an exit // that can't fund its hops just stalls at step zero. Refuse to start // one Savings can't carry, with the number on the table. - const feeEst = estimateExitFeeSat(mgr); + const feeEst = estimateExitFeeSat(mgr, spendables); if ((wallet.spendable || 0) < feeEst) { throw new Error(t('arkExitFeeShort', { need: fmtSats(feeEst), have: fmtSats(wallet.spendable || 0) })); } @@ -3209,6 +3211,7 @@ export function arkFeature(ctx) { const tooSmall = !!q && spendable < q.minSat; return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')), + o.fromCoins ? h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')) : null, 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', diff --git a/src/i18n.js b/src/i18n.js index aa66f3a..c92d532 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -820,8 +820,10 @@ const STR = { arkRenewStageRetry: 'The last check with the server didn’t go through; the wallet keeps trying.', arkRenewingHistory: 'Renewing', arkRenewingOpen: 'See the coins', - arkCoinsExitTitle: 'Unilateral exit', - arkCoinsCoopTitle: 'Co-operative exit', + arkCoinsExitTitle: 'Exit', + arkCoinsCoopTitle: 'Withdraw', + arkCoinsWithdrawBtn: 'Withdraw {amount}', + arkCoinsExitBtnAmt: 'Exit {amount}', arkOffboardBadAddress: 'Enter a valid on-chain address for this network.', arkCoinsExitBtn: 'Exit now', arkExitConfirmTitle: 'Start a unilateral exit?', diff --git a/tools/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js index 506bca6..03e65d5 100644 --- a/tools/ark/cooperative-exit-ui-test.js +++ b/tools/ark/cooperative-exit-ui-test.js @@ -66,10 +66,9 @@ try { }); 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 coopButton = () => page.evaluate(() => document.querySelector('.ark-coop-go').click()); const send = () => page.evaluate(() => [...document.querySelectorAll('.card')] - .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click()); + .find((c) => c.querySelector('h3')?.textContent === 'Withdraw').querySelector('.btn-primary').click()); const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value); // The balance card's gear is the way in: Spending's settings lead with // its coins and carry what a unilateral exit costs; the home screen no @@ -114,13 +113,16 @@ try { await page.evaluate(() => { window.forceArkReady = false; coopApp.wallet.saveFeatureState('arkDepth', null); coopApp.ui.screen = 'wallet'; coopApp.render(); }); await manage(); - assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']); + // the three actions sit right under the table, named for the ticked total; no explainer cards + assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew now', 'Withdraw 20,000 sats', 'Exit 20,000 sats']); + assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), []); 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.match(await page.evaluate(() => document.body.innerText), /one transaction into Savings, ready after a single confirmation/, 'the withdraw explainer is on the form'); 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'); @@ -165,7 +167,7 @@ try { // ticking a subset on Manage: exactly those coins go, no split, the amount is theirs and not a field await manage(); await page.evaluate(() => { coopApp.ui.arkCoinsSel = new Set(['coin-b']); coopApp.render(); }); - assert.match(await page.evaluate(() => [...document.querySelectorAll('.card')].find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').innerText), /Only the coin ticked above will move[\s\S]*Move 8,000 sats to Savings/); + assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew 1 selected for free', 'Withdraw 8,000 sats', 'Exit 8,000 sats'], 'the buttons name the ticked total'); await coopButton(); await page.waitForSelector('.ark-coop-chosen'); assert.match(await page.$eval('.ark-coop-chosen', (e) => e.textContent), /1 selected coin · 8,000 sats/); @@ -180,7 +182,7 @@ try { 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)); + assert(await page.evaluate(() => document.querySelector('.ark-coop-go').disabled)); await page.evaluate(() => { const { ui, render } = coopApp; ui.arkCoinsPage = null; ui.tab = 'send'; diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js index 419ed21..f8fc886 100644 --- a/tools/ark/custody-ui-test.js +++ b/tools/ark/custody-ui-test.js @@ -85,20 +85,22 @@ try { assert.doesNotMatch(tx, /Coins renew in the last hours|Every transfer adds a hop/, 'only the chosen strategy is described'); assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card'); const order = await page.$$eval('.card h4, .card h3', (es) => es.map((e) => e.textContent).filter((x) => /Spending coins|Custody|Renewal/.test(x))); - assert.deepEqual(order, ['Spending coins', 'Custody', 'Renewal'], 'coins first, then the strategy, then renewal'); + assert.deepEqual(order, ['Spending coins', 'Custody'], 'coins first, then the strategy; no explainer cards'); assert.match(tx, /held by Coinos/, 'the coin past its timelock is held, not "expired"'); assert.doesNotMatch(tx, /5,000 sats\s+expired/, 'the row never says "expired"'); - assert.match(tx, /With “Allow coins to expire”, nothing renews on its own/); + assert.doesNotMatch(tx, /nothing renews on its own/, 'the renewal explainer is not on the page…'); + const renewSheet = async () => { await page.click('.ark-renew-ask'); await sleep(200); const t = await text(); await page.evaluate(() => [...document.querySelectorAll('.ark-renew-confirm button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); return t; }; + assert.match(await renewSheet(), /With “Allow coins to expire”, nothing renews on its own[\s\S]*more than 15 days left/, '…it is on the Renew confirmation, with the fee schedule'); if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true }); console.log('✓ the custody card: a drop-down of three strategies with their prices, coins expire by default, one description, a past-timelock coin shown as held'); // the unilateral exit only counts the coin still inside its timelock - assert.match(tx, /Exit now/); - const exitBtn = await page.$$eval('button', (bs) => bs.filter((b) => b.textContent === 'Exit now').map((b) => b.disabled)); - assert.deepEqual(exitBtn, [false]); - await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Exit now').click()); await sleep(200); + assert.equal(await page.$eval('.ark-exit-ask', (b) => b.textContent), 'Exit 20,800 sats', 'the exit button names the ticked coins still inside their timelock'); + assert.equal(await page.$eval('.ark-exit-ask', (b) => b.disabled), false); + await page.click('.ark-exit-ask'); await sleep(200); tx = await text(); assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/); + assert.match(tx, /If the server vanished today/, 'the exit explainer is on the confirmation'); await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); console.log('✓ a unilateral exit counts only the coin still inside its timelock'); @@ -108,8 +110,7 @@ try { assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet'); tx = await text(); assert.match(tx, /Custody choice saved/); - assert.doesNotMatch(tx, /nothing renews on its own/); - assert.match(tx, /renews coins automatically once they are close to expiring/); + assert.match(await renewSheet(), /renews coins automatically once they are close to expiring/); assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /Coins renew in the last hours/); const calls = await page.evaluate(() => coopFeature.autoRefreshCalls); assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)'); @@ -125,7 +126,7 @@ try { console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it'); await page.select('#ark-custody-select', 'custodial'); await sleep(300); assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); - assert.match(await text(), /nothing renews on its own/); + assert.match(await renewSheet(), /nothing renews on its own/); console.log('✓ the other tiers switch the same way, and back'); assert.deepEqual(errors, [], 'no page errors'); console.log('\n✅ custody is a choice, made on the Spending page'); |
