aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-04 09:28:36 -0700
committerAdam Soltys <[email protected]>2026-08-04 09:28:36 -0700
commite462bc1e7756dcbf7c651ee282e96ee8c48b5a14 (patch)
treebdb0a8276fffc8cce54adfb978ea6d22cec290b8 /src
parent9fd8887dcdd5e0fc1067a91032bfd18eb25f0202 (diff)
downloadcoinosv3-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.js1
-rw-r--r--src/ark/validate.js8
-rw-r--r--src/features/ark.js45
-rw-r--r--src/i18n.js2
4 files changed, 54 insertions, 2 deletions
diff --git a/src/app.js b/src/app.js
index cfd2f49..e68fc52 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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',