aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 16:40:25 -0700
committerAdam Soltys <[email protected]>2026-08-02 16:40:25 -0700
commit40fef19510d66209cb271af46d5cd7ec19dd6010 (patch)
treeca1c18a4dcbfb94231200a716bc258180eb3dd50
parentbf34851a02e253df559e14380d026722a240f789 (diff)
downloadcoinosv3-40fef19510d66209cb271af46d5cd7ec19dd6010.tar.gz
coinosv3-40fef19510d66209cb271af46d5cd7ec19dd6010.zip
Ark: automatic vtxo upkeep, and Move money inline on the balance card
Refresh/consolidation is no longer a user concern: after each sync the wallet renews automatically when any spendable vtxo is within ~a week of expiry or fragmentation reaches six vtxos (a send needs one vtxo that covers the amount), throttled to one attempt per half hour and never while another round is in flight. The manual refresh button is gone. The separate Move money page is gone too — the button on the balance card now unfolds an inline panel with an Into Spending / Into Savings toggle: boarding happens right there, and Into Savings hands off to the existing exit page (cooperative offboard + unilateral exit unchanged). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--src/app.js4
-rw-r--r--src/features/ark.js93
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css3
4 files changed, 63 insertions, 38 deletions
diff --git a/src/app.js b/src/app.js
index 50bf2e6..a67a154 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1960,7 +1960,9 @@ function balanceCard() {
: null,
// "Move money" and friends: the only door between the two balances.
...featureAll('balanceActions').map((a2) =>
- h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label))
+ h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)),
+ // A feature can unfold UI right on the card (ark's inline move panel).
+ featureHook('balanceExtra')
);
}
diff --git a/src/features/ark.js b/src/features/ark.js
index c9ea4e9..d98e92e 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -342,7 +342,9 @@ export function arkFeature(ctx) {
// them. Publishing on connect guarantees every device's ark balance
// reaches the relay (and gets merged) without needing a transaction first.
if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); } catch {} }
- const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {});
+ const tick = () => mgr.sync().catch(() => {})
+ .then(() => driveExits(mgr)).catch(() => {})
+ .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {});
tick();
// Reconcile once on connect: a vtxo synced in from another device (or
// one this device held while a spend happened elsewhere) is checked
@@ -870,10 +872,7 @@ export function arkFeature(ctx) {
const parts = Object.entries(counts).map(([type, n]) => t(LABEL[type] || 'arkPendingActions', { n }));
return parts.length ? h('div', { class: 'small muted' }, parts.join(' · ')) : null;
})(),
- spendables.length >= 1
- ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh },
- ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length }))
- : null,
+ // Refresh/consolidation is automatic (maybeAutoRefresh) — no button.
// Moving funds out (cooperative offboard / unilateral exit) lives on its
// own page, reached from the "Exit" link on the Ark balance line.
spendables.length >= 1
@@ -1301,15 +1300,29 @@ export function arkFeature(ctx) {
ui.arkBusy = null; render();
}
- async function doArkRefresh() {
- ui.arkBusy = 'refresh'; ui.arkError = ''; render();
+ // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount
+ // (fragmentation breaks coin selection) and every vtxo expires at
+ // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than
+ // exposing a refresh button, consolidate-and-renew automatically when a
+ // vtxo is inside the renewal window or fragmentation builds up. The round
+ // fee this spends is the cost of keeping the money spendable at all.
+ let arkAutoRefreshAt = 0;
+ async function maybeAutoRefresh(mgr) {
+ if (wallet.watchOnly || !mgr || !mgr.state) return;
+ if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return;
+ const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable');
+ if (!spendables.length) return;
+ // never start a round while any other action is still in flight
+ if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
+ arkAutoRefreshAt = Date.now();
try {
- await ark.refresh();
- toast(t('arkRefreshStarted'), 5000);
- } catch (e) {
- ui.arkError = e.message;
- }
- ui.arkBusy = null; render();
+ const tip = await mgr.chain.tipHeight();
+ const RENEW_BLOCKS = getNetwork() === 'regtest' ? 24 : 1008; // ~1 week of margin on mainnet
+ const expiring = spendables.some((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS);
+ const fragmented = spendables.length >= 6;
+ if (!expiring && !fragmented) return;
+ await mgr.refresh();
+ } catch {} // transient — the next throttled attempt retries
}
// Moving money between the two balances, in plain terms. This is the only
@@ -1342,28 +1355,29 @@ export function arkFeature(ctx) {
t('arkBoardNoFunds', { n: minBoard.toLocaleString() })));
}
- function movePage() {
- const b = arkBalance();
- const back = () => { ui.arkMovePage = false; ui.arkError = ''; render(); };
+ // Moving money between the two balances, inline on the balance card — the
+ // one door between the rails opens right where the balances live.
+ function movePanel() {
if (!ark) connectArk().catch(() => {});
- return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' },
- h('div', { class: 'row between' },
- h('h3', { style: 'margin:0' }, t('moveTitle')),
- h('span', { class: 'linklike small', onClick: back }, t('back'))),
- ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
- h('div', { class: 'card col', style: 'gap:10px' },
- h('h4', { style: 'margin:0' }, t('moveToSpending')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSpendingDesc')),
- ark ? boardForm() : h('div', { class: 'row gap6', style: 'align-items:center' },
- h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting')))),
- h('div', { class: 'card col', style: 'gap:10px' },
- h('h4', { style: 'margin:0' }, t('moveToSaving')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSavingDesc')),
- h('div', { class: 'small faint' }, t('moveAvailable', { n: fmtAmount(b ? b.spendableSat : 0) + ' ' + unitLabel() })),
- h('button', {
- class: 'btn-block', disabled: !b || !b.spendableSat,
- onClick: () => { ui.arkMovePage = false; ui.arkExitPage = true; ui.arkError = ''; render(); },
- }, t('moveToSavingBtn'))));
+ const dir = ui.arkMoveDir || 'toSpending';
+ const b = arkBalance();
+ return h('div', { class: 'col balance-move' },
+ h('div', { class: 'seg' },
+ h('button', { class: dir === 'toSpending' ? 'active' : '', onClick: () => { ui.arkMoveDir = 'toSpending'; ui.arkError = ''; render(); } }, t('moveToSpending')),
+ h('button', { class: dir === 'toSaving' ? 'active' : '', onClick: () => { ui.arkMoveDir = 'toSaving'; ui.arkError = ''; render(); } }, t('moveToSaving'))),
+ dir === 'toSpending'
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSpendingDesc')),
+ ark ? boardForm() : h('div', { class: 'row gap6', style: 'align-items:center' },
+ h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))))
+ : h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSavingDesc')),
+ h('div', { class: 'small faint' }, t('moveAvailable', { n: fmtAmount(b ? b.spendableSat : 0) + ' ' + unitLabel() })),
+ h('button', {
+ class: 'btn-block', disabled: !b || !b.spendableSat,
+ onClick: () => { ui.arkMoveOpen = false; ui.arkExitPage = true; ui.arkError = ''; render(); },
+ }, t('moveToSavingBtn'))),
+ ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null);
}
// The Ark receive pane: connect-on-demand, then address + board form.
@@ -1535,7 +1549,7 @@ export function arkFeature(ctx) {
id: 'ark',
init() { initArk(); },
stop() { stopArk(); },
- screenView() { return ui.arkMovePage ? movePage() : ui.arkExitPage ? arkExitPage() : null; },
+ screenView() { return ui.arkExitPage ? arkExitPage() : null; },
receiveModes() {
if (!arkAvailable()) return [];
return [{ id: 'ark', label: t('receiveArkTab'), icon: ARK_MARK(18), render: (seg) => arkReceivePane(seg) }];
@@ -1652,7 +1666,14 @@ export function arkFeature(ctx) {
// "Move money" under the balance — the one door between the two balances.
balanceActions() {
if (wallet.watchOnly || !arkAvailable()) return [];
- return [{ label: t('moveMoney'), onClick: () => { ui.arkMovePage = true; ui.arkError = ''; render(); } }];
+ return [{
+ label: ui.arkMoveOpen ? t('hide') : t('moveMoney'),
+ onClick: () => { ui.arkMoveOpen = !ui.arkMoveOpen; ui.arkError = ''; render(); },
+ }];
+ },
+ balanceExtra() {
+ if (!ui.arkMoveOpen || wallet.watchOnly || !arkAvailable()) return null;
+ return movePanel();
},
spendingSat() {
const b = arkBalance();
diff --git a/src/i18n.js b/src/i18n.js
index 32e7685..55b55d7 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -140,7 +140,6 @@ const STR = {
hiwGiftsTitle: 'Gifts',
hiwGifts1: 'You can gift bitcoin with a link: whoever opens it claims the coins into their own wallet — no app, no signup. On-chain gifts lock the coins until claimed and can be cancelled or reclaimed; Ark gifts claim instantly and free, even into a brand-new empty wallet. A gift can also be locked to a specific nostr account so only they can claim it.',
moveMoney: 'Move money',
- moveTitle: 'Move money',
moveToSpending: 'Into Spending',
moveToSpendingDesc: 'Make money instantly spendable, with no fees to send it.',
moveToSaving: 'Into Savings',
diff --git a/src/style.css b/src/style.css
index c5fde7c..2afabac 100644
--- a/src/style.css
+++ b/src/style.css
@@ -382,3 +382,6 @@ hr.divider { margin: 16px 0; }
/* Settings: section heading between card groups. */
.settings-section { margin: 8px 0 -6px 4px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
+
+/* Inline move-money panel on the balance card. */
+.balance-move { gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); text-align: left; }