diff options
| -rw-r--r-- | src/features/ark.js | 58 | ||||
| -rw-r--r-- | src/i18n.js | 4 | ||||
| -rw-r--r-- | tools/ark/cooperative-exit-ui-test.js | 13 |
3 files changed, 53 insertions, 22 deletions
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)); diff --git a/src/i18n.js b/src/i18n.js index e9e298f..71ad321 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -778,6 +778,10 @@ const STR = { arkFeeService: 'Service fee', arkCoinsExpired: 'expired', arkCoinsHeld: 'held by Coinos', + arkCoopSelected: 'Only the {n} coins ticked above will move.', + arkCoopSelectedOne: 'Only the coin ticked above will move.', + arkCoopSelectedAmt: '{n} selected coins · {amount}', + arkCoopSelectedAmtOne: '1 selected coin · {amount}', arkCustodyTitle: 'Custody', arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos. Renewing a coin before it expires keeps that right for another {days} days and costs a small fee. Coinos keeps honoring expired coins, so renewing is a choice:', arkCustodyCustodial: 'Allow coins to expire', diff --git a/tools/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js index d8fa0d8..506bca6 100644 --- a/tools/ark/cooperative-exit-ui-test.js +++ b/tools/ark/cooperative-exit-ui-test.js @@ -162,6 +162,19 @@ try { 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'); + // 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/); + 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/); + assert.equal(await page.$('input[type="number"]'), null, 'no amount field for chosen coins'); + await send(); + await page.waitForFunction(() => coopCalls.length === 3 && !coopApp.ui.arkBusy); + assert.deepEqual(await page.evaluate(() => ({ type: coopCalls[2].type, ids: coopCalls[2].ids, address: coopCalls[2].address })), { type: 'offboard', ids: ['coin-b'], address: savings }); + console.log('✓ Ticking a subset on Manage offboards exactly those coins, without splitting'); + 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); |
