diff options
| author | Adam Soltys <[email protected]> | 2026-08-04 09:28:36 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-04 09:28:36 -0700 |
| commit | e462bc1e7756dcbf7c651ee282e96ee8c48b5a14 (patch) | |
| tree | bdb0a8276fffc8cce54adfb978ea6d22cec290b8 /src/features | |
| parent | 9fd8887dcdd5e0fc1067a91032bfd18eb25f0202 (diff) | |
| download | coinosv3-e462bc1e7756dcbf7c651ee282e96ee8c48b5a14.tar.gz coinosv3-e462bc1e7756dcbf7c651ee282e96ee8c48b5a14.zip | |
A wallet too small to renew warns before its coins expire; dust never dies
The warning: when the Spending balance minus round fee is under the
330-sat round minimum AND a vtxo enters the renewal window, auto-refresh
can't save the coins — a wallet-screen notice (and a daily local
notification, when permitted) says to receive any amount or spend
before the deadline. New walletNotices feature hook renders under the
balance card.
The amnesty: sub-dust vtxos have no unilateral exit to protect, and
regtest proves captaind keeps cosigning their spends past expiryHeight —
even after the watchman claims the backing on-chain
(tools/ark/expired-dust-test.js). Only hal's own receive validation
stood in the way; it now exempts sub-330 vtxos from the expiry check.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015QwAtBSpKvECqp1rHLHjJk
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 45 |
1 files changed, 44 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index c61d301..2fa151f 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1573,11 +1573,15 @@ export function arkFeature(ctx) { // 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; + // Set when the balance is too small to renew itself (total minus round fee + // under the server's 330-sat output minimum) AND a vtxo is inside the + // renewal window: { deadlineMs, sat }. The wallet-screen notice reads this. + let arkRenewWarn = null; 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; + if (!spendables.length) { arkRenewWarn = null; 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(); @@ -1586,11 +1590,42 @@ export function arkFeature(ctx) { 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; + const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0); + const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330; + if (expiring && !renewable) { + // Can't save these coins ourselves — tell the user while receiving + // any amount (or spending) can still beat the clock. + const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity)); + const blockMs = getNetwork() === 'regtest' ? 10_000 : 600_000; + arkRenewWarn = { deadlineMs: Date.now() + Math.max(0, minExpiry - tip) * blockMs, sat: totalSat }; + notifyRenewWarn(); + render(); + return; + } + arkRenewWarn = null; if (!expiring && !fragmented) return; await mgr.refresh(); + render(); } catch {} // transient — the next throttled attempt retries } + // A local heads-up (at most one a day) for users who granted notifications — + // the in-app notice alone can't reach someone who isn't looking. + function notifyRenewWarn() { + try { + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; + const KEY = 'arkRenewNotifiedAt'; + if (Date.now() - (Number(localStorage.getItem(KEY)) || 0) < 24 * 3600_000) return; + localStorage.setItem(KEY, String(Date.now())); + const body = t('arkRenewWarnPush', { + date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), + }); + navigator.serviceWorker?.ready + .then((reg) => reg.showNotification('coinos', { body })) + .catch(() => { try { new Notification('coinos', { body }); } catch {} }); + } catch {} + } + // Moving money between the two balances, in plain terms. This is the only // place the two rails meet, and it never names them: Saving is on-chain, // Spending is instant, and the wallet handles the rest. @@ -1967,6 +2002,14 @@ export function arkFeature(ctx) { // say where it went rather than letting it vanish for a confirmation. return b.boardingSat > 0 ? [{ label: t('movingLabel'), sat: b.boardingSat }] : []; }, + walletNotices() { + if (!arkRenewWarn || wallet.watchOnly) return []; + return [h('div', { class: 'notice err', style: 'margin:12px 0 0' }, + t('arkRenewWarn', { + amount: fmtAmount(arkRenewWarn.sat) + ' ' + unitLabel(), + date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), + }))]; + }, decorateTxRow(tx) { const s = arkStateNow(); if (!s) return null; |
