aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 23:12:42 -0700
committerAdam Soltys <[email protected]>2026-10-22 23:12:42 -0700
commitaf351e13947831ba54d4233ba80f46dab89e8c42 (patch)
tree52b85c84140034b5b3ba6e6a3ccb9d747be5a19a /src/features/ark.js
parent56ebe0367021835be7371c70fb2295c439b594c4 (diff)
downloadcoinosv3-af351e13947831ba54d4233ba80f46dab89e8c42.tar.gz
coinosv3-af351e13947831ba54d4233ba80f46dab89e8c42.zip
Manage page: Renew, Withdraw and Exit sit under the coin table and act on the ticked coins; a unilateral exit can be partial; the explainers moved to the confirmations
Adam, 2026-10-23: the three buttons belong right below the table so it is clear they act on the selection, "co-operative exit" is Withdraw and "unilateral exit" is Exit, Exit names the ticked total and takes just those coins, and the explainer cards were a wall — they now appear where the action is decided: the renewal text and fee schedule on the Renew confirmation, the withdraw note on the form it opens, the exit cost comparison on the Exit confirmation. The page is the coins, their three buttons, and the custody strategy. The buttons stay (disabled) while every coin is in a round. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js105
1 files changed, 54 insertions, 51 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',