aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 17:07:26 -0700
committerAdam Soltys <[email protected]>2026-08-02 17:07:26 -0700
commitbff3692ed92dbce4aa1e71f1ba6ec491645eb5db (patch)
tree9840735d57148571b8adf3f9e4e4bf4279451d61 /src
parent404350a32f26c892fa7b3b65f222ebc955be8a36 (diff)
downloadcoinosv3-bff3692ed92dbce4aa1e71f1ba6ec491645eb5db.tar.gz
coinosv3-bff3692ed92dbce4aa1e71f1ba6ec491645eb5db.zip
Ark: partial moves to Savings via an exact-amount split
Into Savings now has an amount field with a Max button. Max (or the full balance) keeps the existing whole-balance cooperative offboard. A partial amount first mints a vtxo of exactly that amount with a self-send — the mailbox delivers self-sends, verified on regtest — then offboards just that vtxo, so the number typed is the number that leaves Spending (fees still come off what arrives, shown on the success screen). startOffboard accepts a vtxo subset for this; an exact-match vtxo skips the split. The unilateral exit page remains reachable from a link under the field. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js12
-rw-r--r--src/features/ark.js59
-rw-r--r--src/i18n.js3
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',