aboutsummaryrefslogtreecommitdiffstats
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
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
-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
-rw-r--r--tools/ark/expired-dust-test.js102
5 files changed, 156 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',
diff --git a/tools/ark/expired-dust-test.js b/tools/ark/expired-dust-test.js
new file mode 100644
index 0000000..4741fb8
--- /dev/null
+++ b/tools/ark/expired-dust-test.js
@@ -0,0 +1,102 @@
+// Does the ASP keep honoring a sub-dust vtxo PAST its expiry height?
+//
+// Needs captaind running with a short vtxo_lifetime (e.g. 40 blocks) so the
+// test can mine past expiry without aging the rest of the regtest chain.
+//
+// board fresh coins -> dust to a bearer key -> mine past expiry (watchman
+// sweeps the board) -> bearer sweeps its expired dust to a claimer
+// -> claimer's client validates it (dust exemption) -> and for contrast,
+// an expired NON-dust spend, to document the server's actual policy.
+//
+// Usage: bun tools/ark/expired-dust-test.js
+
+import { execSync } from 'node:child_process';
+import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { HDKey } from '@scure/bip32';
+
+import { ArkManager } from '/home/adam/coinosv3/src/ark/manager.js';
+
+const ARK = 'http://127.0.0.1:3535';
+const ESPLORA = 'http://127.0.0.1:30002';
+const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+const mine = (n = 1) => sh(`${BCLI} -generate ${n} >/dev/null`);
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+const newWallet = async () => {
+ const store = { raw: null };
+ const storage = { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } };
+ const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'");
+ return new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
+};
+const waitBalance = async (mgr, want, tries = 15) => {
+ for (let i = 0; i < tries && mgr.balance().spendableSat < want; i++) { await mgr.sync(); await sleep(1500); }
+ return mgr.balance().spendableSat;
+};
+
+console.log('[setup] board fresh coins under the short lifetime');
+const S = await newWallet();
+const G1 = await newWallet(); // 1-sat bearer "gift" key
+const C = await newWallet(); // claimer
+check('server lifetime is short', S.info.vtxoExpiryDelta <= 60, `vtxoExpiryDelta=${S.info.vtxoExpiryDelta}`);
+
+const { actionId, fundingAddress, feeSat } = await S.startBoard(20000);
+const raw = sh(`${BCLI} createrawtransaction '[]' '[{"${fundingAddress}":0.00020000}]'`);
+const funded = JSON.parse(sh(`${BCLI} fundrawtransaction ${raw} '{"changePosition":1}'`));
+const signed = JSON.parse(sh(`${BCLI} signrawtransactionwithwallet ${funded.hex}`));
+const fundingTxid = sh(`${BCLI} sendrawtransaction ${signed.hex}`);
+await S.completeBoard(actionId, fundingTxid);
+mine(2);
+for (let i = 0; i < 20 && S.pendingActions().length; i++) { await sleep(2000); await S.sync(); }
+check('boarded', S.balance().spendableSat === 20000 - feeSat, JSON.stringify(S.balance()));
+
+console.log('\n[pre-expiry] 1-sat gift to the bearer key');
+await S.send(G1.address(), 1);
+check('bearer holds 1 sat', (await waitBalance(G1, 1)) === 1);
+const expiry = G1.vtxos()[0].expiryHeight;
+let tip = await S.chain.tipHeight();
+console.log(` dust vtxo expires at ${expiry}, tip ${tip}`);
+
+console.log('\n[expire] mining past expiry, waiting for the watchman to CLAIM the board');
+mine(expiry - tip + 5);
+// esplora indexes the fresh blocks with a lag — poll until it catches up
+for (let i = 0; i < 20 && (tip = await S.chain.tipHeight()) <= expiry; i++) await sleep(1500);
+check('tip is past expiry', tip > expiry, `tip ${tip} vs expiry ${expiry}`);
+// the watchman's claim spends the board funding output — poll for it, mining
+// now and then so its claim tx also confirms (process_interval is 60s)
+let sweptAt = null;
+for (let i = 0; i < 15 && !sweptAt; i++) {
+ await sleep(20000);
+ mine(1);
+ try {
+ const o = await fetch(`${ESPLORA}/tx/${fundingTxid}/outspend/0`).then((r) => r.json());
+ if (o.spent) sweptAt = o.txid;
+ } catch {}
+}
+check('watchman claimed the expired board on-chain', !!sweptAt, sweptAt || 'not swept after 5 min — claim below tests pre-sweep behavior only');
+
+console.log('\n[claim] bearer sweeps its EXPIRED dust to the claimer');
+let claimErr = null;
+try { await G1.send(C.address(), 1); } catch (e) { claimErr = e.message; }
+check('server cosigned the expired-dust claim', !claimErr, claimErr || '');
+if (!claimErr) {
+ const got = await waitBalance(C, 1);
+ check('claimer validated + received the expired dust', got === 1, `${got} sat`);
+}
+
+console.log('\n[contrast] expired NON-dust spend from the sender');
+const alice = `${process.env.HOME}/bark/target/debug/bark --datadir ${process.env.HOME}/ark-regtest/alice`;
+let bigErr = null;
+try { await S.send(sh(`${alice} -q address`), 1000); } catch (e) { bigErr = e.message; }
+console.log(` server ${bigErr ? 'REJECTED: ' + bigErr : 'accepted the expired non-dust spend'}`);
+
+console.log(ok ? '\n✅ expired dust stays spendable' : '\n❌ see failures above');
+process.exit(ok ? 0 : 1);