diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 12 | ||||
| -rw-r--r-- | src/features/ark.js | 59 | ||||
| -rw-r--r-- | src/i18n.js | 3 |
3 files changed, 64 insertions, 10 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 3b54582..5617cf1 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -363,13 +363,19 @@ export class ArkManager { return action; } - // ---- offboard (collaborative exit: all spendable vtxos -> one on-chain output) ---- - async startOffboard(spk, address) { + // ---- offboard (collaborative exit: spendable vtxos -> one on-chain output) ---- + // Whole vtxos only — there is no change on this path. Default is everything + // spendable; vtxoIds selects a subset (the feature splits an exact-amount + // vtxo first via a self-send when the user asks for a partial move). + async startOffboard(spk, address, vtxoIds) { if (this.pendingActions().some((a) => a.type === 'offboard')) { throw new Error('an offboard is already in progress'); } - const inputs = this.state.vtxos.filter((v) => v.state === 'spendable'); + const inputs = vtxoIds + ? vtxoIds.map((id) => this._vtxo(id)) + : this.state.vtxos.filter((v) => v.state === 'spendable'); if (!inputs.length) throw new Error('no spendable ark balance'); + if (inputs.some((v) => !v || v.state !== 'spendable')) throw new Error('vtxo not spendable'); if (this.info.maxOffboardInputs && inputs.length > this.info.maxOffboardInputs) { throw new Error(`too many coins for one offboard (${inputs.length}) — refresh/consolidate first`); } diff --git a/src/features/ark.js b/src/features/ark.js index 4d9242c..09670fa 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1285,13 +1285,37 @@ export function arkFeature(ctx) { // Offboard the whole ark balance back into this wallet's own on-chain // receive address — the mirror image of boarding. - async function doArkOffboard() { + // Offboard `amountSat` (or everything when omitted). An offboard moves + // whole vtxos — no change — so a partial amount first mints an exact-amount + // vtxo via a self-send (regtest-verified: the mailbox delivers self-sends), + // then exits just that one. The number the user typed is the number that + // leaves Spending; fees come off what arrives in Savings. + async function doArkOffboard(amountSat) { ui.arkBusy = 'offboard'; ui.arkError = ''; render(); try { const mgr = await connectArk(); + const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); + const total = spendables().reduce((n, v) => n + v.amountSat, 0); + let ids; // undefined = all spendable vtxos + if (amountSat && amountSat < total) { + let target = spendables().find((v) => v.amountSat === amountSat); + if (!target) { + const before = new Set(spendables().map((v) => v.id)); + await mgr.send(mgr.address(), amountSat); + for (let i = 0; i < 30 && !target; i++) { + await new Promise((r) => setTimeout(r, 2000)); + await mgr.sync().catch(() => {}); + target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); + } + if (!target) throw new Error(t('arkSplitTimeout')); + } + ids = [target.id]; + } const address = wallet.freshReceive().address; const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); - const action = await mgr.startOffboard(spk, address); + const action = await mgr.startOffboard(spk, address, ids); + ui.arkOffboardAmt = ''; + ui.arkMoveOpen = false; ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat }; wallet.scan().catch(() => {}); // surface the incoming pending tx promptly } catch (e) { @@ -1373,10 +1397,31 @@ export function arkFeature(ctx) { : h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSavingDesc')), // no "X sats in Spending" line — the balance sits right above on this card - h('button', { - class: 'btn-block', disabled: !b || !b.spendableSat, - onClick: () => { ui.arkMoveOpen = false; ui.arkExitPage = true; ui.arkError = ''; render(); }, - }, t('moveToSavingBtn'))), + (() => { + const spendable = b ? b.spendableSat : 0; + const sats = parseInt((ui.arkOffboardAmt || '').trim(), 10) || 0; + const valid = sats > 0 && sats <= spendable; + return h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'input-group' }, + h('input', { + type: 'number', inputmode: 'numeric', min: '0', + placeholder: t('arkOffboardAmtPlaceholder'), + value: ui.arkOffboardAmt || '', + onInput: (e) => { ui.arkOffboardAmt = e.target.value; render(); }, + }), + h('button', { class: 'btn-ghost', onClick: () => { ui.arkOffboardAmt = String(spendable); render(); } }, t('max')), + h('button', { + class: 'btn-primary', disabled: !!ui.arkBusy || !valid, + onClick: () => doArkOffboard(sats), + }, ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('moveToSavingBtn'))), + ui.arkBusy === 'offboard' && sats < spendable + ? h('div', { class: 'small muted', style: 'text-align:center' }, t('arkSplitting')) + : null, + h('button', { + class: 'linklike small', style: 'align-self:center', + onClick: () => { ui.arkMoveOpen = false; ui.arkExitPage = true; ui.arkError = ''; render(); }, + }, t('arkUniTitle'))); + })()), ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null); } @@ -1528,7 +1573,7 @@ export function arkFeature(ctx) { h('h4', { style: 'margin:0' }, t('arkCoopTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')), spendable > 0 - ? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: doArkOffboard }, + ? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: () => doArkOffboard() }, ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() })) : h('div', { class: 'small faint' }, t('arkExitNoBalance'))), // unilateral diff --git a/src/i18n.js b/src/i18n.js index ed3cdbc..668df54 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -199,6 +199,9 @@ const STR = { nostrSection: 'Nostr', advancedSettings: 'Advanced settings', recoveryDesc: 'The twelve words that back up this wallet. Anyone who sees them can steal your funds.', + arkOffboardAmtPlaceholder: 'Amount', + arkSplitting: 'Carving out that exact amount — this can take a minute…', + arkSplitTimeout: 'The split is taking longer than expected — your balance is intact; try again shortly.', copyAddress: 'Copy address', paymentReceived: 'Payment received!', tapToProceed: 'Tap anywhere to proceed', |
