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 | |
| 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')
| -rw-r--r-- | src/app.js | 1 | ||||
| -rw-r--r-- | src/ark/validate.js | 8 | ||||
| -rw-r--r-- | src/features/ark.js | 45 | ||||
| -rw-r--r-- | src/i18n.js | 2 |
4 files changed, 54 insertions, 2 deletions
@@ -2447,6 +2447,7 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + ...featureAll('walletNotices'), tabsBar(), pane, ui.tour != null ? tourOverlay() : null diff --git a/src/ark/validate.js b/src/ark/validate.js index f7aecb9..d09f6ec 100644 --- a/src/ark/validate.js +++ b/src/ark/validate.js @@ -36,8 +36,14 @@ export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, a if (!vtxo.genesis.length) fail('vtxo has no genesis transitions'); // -- expiry -- + // Sub-dust vtxos outlive their expiryHeight: they have no unilateral exit + // to protect (below dust there is no on-chain output they could become), + // and the coinos ASP keeps honoring them off-chain after sweeping their + // round — an explicit IOU where the trustless path never existed anyway. const tip = await chain.tipHeight(); - if (vtxo.expiryHeight <= tip) fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`); + if (vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) { + fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`); + } // -- anchor: onchain, confirmed, and its output matches what the first // transition claims to spend -- 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; diff --git a/src/i18n.js b/src/i18n.js index 6fcf01f..fa0a48f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -459,6 +459,8 @@ const STR = { giftSourceChain: 'On-chain', giftSourceArk: 'Spending', giftArkNote: 'Sent from Spending — the link claims instantly, with no fee.', + arkRenewWarn: 'Your Spending balance ({amount}) is too small to renew itself and will expire around {date}. Receiving any amount — or spending it — keeps it alive.', + arkRenewWarnPush: 'Your Spending balance is too small to renew itself and expires around {date}. Receive any amount or spend it to keep it alive.', giftArkOutstanding: '{n} unclaimed instant gift(s)', giftArkRevoked: 'Gift swept back into Spending.', claimArkFree: 'instant, no fee', |
