From 0fce7161a9041887f2864db00acebca8ad3c9a20 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 22 Oct 2026 22:40:59 -0700 Subject: Co-operative exit takes the ticked coins: a subset on the Manage page goes on-chain as exactly those coins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, 2026-10-23: the checkboxes only fed Renew; the exit button always prefilled the whole balance. The manager's offboard always took a coin list, and the fee quote prices each coin by its own expiry, so the page now passes the selection through: the card says which coins will move and prices their total, the exit form shows "N selected coins · amount" instead of an amount field, the quote is for those coins, and no fresh coin is split off first. Nothing ticked (or everything) still means the whole balance, with the editable amount as before. Co-Authored-By: Claude Fable 5.1 --- src/features/ark.js | 58 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 22 deletions(-) (limited to 'src/features') diff --git a/src/features/ark.js b/src/features/ark.js index 984c6a2..1c57cd6 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2578,17 +2578,26 @@ export function arkFeature(ctx) { : 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('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.arkRenewConfirm = 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() }))), + // 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: () => { + 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; + ui.arkOffboarded = null; ui.arkError = ''; + ctx.showSend({ fresh: true }); + ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds }; + render(); + } }, t('arkOffboardBtn', { n: fmtAmount(coopSat) + ' ' + unitLabel() }))); + })(), h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), h('p', { class: 'small muted', style: 'margin:0' }, @@ -3132,7 +3141,7 @@ export function arkFeature(ctx) { async function quoteOffboard(o, address) { o.quoteFor = address; o.quote = null; let quote = null; - try { quote = await (await connectArk()).offboardQuote(addrScript(address)); } + try { quote = await (await connectArk()).offboardQuote(addrScript(address), o.vtxoIds); } catch {} // no quote: the send itself still says what went wrong if (o.quoteFor !== address) return; // the address changed meanwhile o.quote = quote; o.quoteDone = true; @@ -3140,7 +3149,9 @@ export function arkFeature(ctx) { } function arkOffboardSendView() { const o = ui.arkOffboardSend; - const spendable = arkBalance()?.spendableSat || 0; + // chosen coins: the amount is theirs and is not a field + const chosen = o.vtxoIds ? freeCoins(arkStateNow()).filter((v) => o.vtxoIds.includes(v.id)) : null; + const spendable = chosen ? chosen.reduce((n, v) => n + v.amountSat, 0) : (arkBalance()?.spendableSat || 0); if (o.quoteFor !== o.address) { o.quote = null; o.quoteDone = false; o.quoteFor = o.address; let ok = false; try { addrScript(o.address); ok = true; } catch {} @@ -3160,10 +3171,11 @@ export function arkFeature(ctx) { fee: fmtSats(q.totalSat), chain: fmtSats(q.chainSat), service: fmtSats(q.serviceSat), min: fmtSats(q.minSat), })) : !o.quoteDone ? h('div', { class: 'small faint' }, t('arkOffboardQuoting')) : null, tooSmall ? h('div', { class: 'notice' }, t('arkOffboardBelowMin', { n: fmtSats(spendable), min: fmtSats(q.minSat) })) : null, - h('div', { class: 'input-group' }, - h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, - onInput: (e) => { o.amount = e.target.value; } }), - h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))), + chosen ? h('div', { class: 'small ark-coop-chosen' }, chosen.length === 1 ? t('arkCoopSelectedAmtOne', { amount: fmtSats(spendable) + ' sats' }) : t('arkCoopSelectedAmt', { n: chosen.length, amount: fmtSats(spendable) + ' sats' })) + : h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, + onInput: (e) => { o.amount = e.target.value; } }), + 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: () => { @@ -3173,7 +3185,7 @@ export function arkFeature(ctx) { ui.arkBusy === 'offboard' ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-primary grow', disabled: tooSmall, onClick: () => { - const amount = String(o.amount ?? '').trim(); + const amount = chosen ? String(spendable) : 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; } @@ -3182,7 +3194,7 @@ export function arkFeature(ctx) { ui.arkCoopFailed = false; ui.arkError = t('arkOffboardAmtBelowMin', { min: fmtSats(q.minSat), fee: fmtSats(q.totalSat) }); render(); return; } - doArkOffboard(sats >= spendable ? 0 : sats, o.address); + doArkOffboard(sats >= spendable ? 0 : sats, o.address, chosen ? o.vtxoIds : undefined); } }, t('arkSendBtn'))), // This send IS the cooperative exit. The trustless fallback only // appears when cooperation actually failed (not when the amount was @@ -3218,7 +3230,9 @@ export function arkFeature(ctx) { })()); } - async function doArkOffboard(amountSat, destAddress = null) { + // vtxoIds: exactly these coins go (the coins page's ticked subset); + // otherwise an amount is split off the balance, or everything goes. + async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) { ui.arkBusy = 'offboard'; ui.arkError = ''; ui.arkCoopFailed = false; render(); try { const address = destAddress || wallet.freshReceive().address; @@ -3226,8 +3240,8 @@ export function arkFeature(ctx) { const mgr = await connectArk(); const spendables = () => freeCoins(mgr.state); const total = spendables().reduce((n, v) => n + v.amountSat, 0); - let ids; // undefined = all spendable vtxos - if (amountSat && amountSat < total) { + let ids = vtxoIds; // undefined = all spendable vtxos + if (!ids && amountSat && amountSat < total) { let target = spendables().find((v) => v.amountSat === amountSat); if (!target) { const before = new Set(spendables().map((v) => v.id)); -- cgit v1.2.3