aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-13 23:52:39 -0700
committerAdam Soltys <[email protected]>2026-09-13 23:52:39 -0700
commit4131e5ee24c4891caffdd8f4ca903ee973d104f7 (patch)
tree759df7cebc4de890fa397bdd407857f954200228 /src/features/ark.js
parent4c09f4de3cddc6927a6108baaa4be3a11eef49c4 (diff)
downloadcoinosv3-4131e5ee24c4891caffdd8f4ca903ee973d104f7.tar.gz
coinosv3-4131e5ee24c4891caffdd8f4ca903ee973d104f7.zip
The renewal happens whether or not anyone opens the app.
Every coin expires, and until now the only thing standing between a balance and its expiry was maybeAutoRefresh: it renews when the wallet is opened inside the last 96 blocks, roughly sixteen hours. Bark's own developers say the same premise fails on mobile — people don't reliably open a wallet at the right hour, which is why every mobile Ark client they know of has moved to delegated refreshes and why they are building server-driven renewal. captaind already has it, and ark.coinos.io has been carrying the column unused: SubmitRoundParticipation takes a scheduled_height, and the server holds that participation out of every round until the height arrives, then runs it with nobody online — the round tree branch is signed by the server, which is the whole point of a delegated refresh. The fee is priced at the height it will run at, so a standing order lands in the free bracket rather than today's. So each spendable coin now gets one registered 36 blocks before its expiry, six hourly rounds of slack. It is deliberately not an action: the coin stays spendable, the wallet never reads as busy, and spending it simply kills the order — the server drops a participation whose input is gone, and _driveScheduled drops ours. The claim comes later, whenever the wallet next opens: the mailbox breadcrumb we already post wakes it, and the leaf cosign and forfeit are the ordinary refresh path from there. Nothing about the input's expiry blocks that claim, which is what makes this worth having. A wallet that goes quiet for a month comes back to a coin already renewed and anchored in a fresh tree, instead of one the ASP has swept and would have to honor out of goodwill. One cycle per visit is all it can buy — the replacement's id doesn't exist yet, so its own backstop waits for the next time the app is opened. tools/ark/scheduled-refresh-test.js covers the five behaviours on regtest: the server holds the order until its height, runs it while the wallet is closed, the wallet claims on return, a spend voids the order, and a claim still works after the input has EXPIRED (the last one mines a coin's whole lifetime, so it also re-refreshes the funding wallet first — an expired vtxo can be renewed but not sent). Riding along: the expired-coin notice that was already sitting in the working tree, and manager-test's tampered-vtxo check, which had been reading base64 vtxo bytes as hex since they moved to IndexedDB. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019fMdLypdg5JaU7bdeUFF8v
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js45
1 files changed, 44 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 764affd..222b312 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -2631,7 +2631,16 @@ export function arkFeature(ctx) {
// rescuing) a coin even past its expiry, so aiming inside the free window
// risks nothing. ~16 hours of margin on mainnet.
const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96);
+ // How close to expiry the SERVER's own backstop renewal is aimed. The
+ // window above only helps a wallet someone opens inside it; this one is a
+ // participation registered far in advance that the ASP runs on its own,
+ // whether or not anybody is looking. It sits well inside renewWindow so an
+ // app that IS open still renews first (and the untouched backstop is
+ // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for
+ // the round to actually run in time. Both are in the free fee bracket.
+ const backstopWindow = () => (getNetwork() === 'regtest' ? 6 : 36);
let arkAutoRefreshAt = 0;
+ let arkBackstopAt = 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.
@@ -2663,8 +2672,22 @@ export function arkFeature(ctx) {
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' && !v.expiryRejected);
+ // NOT filtered by expiryRejected. A coin the server refused as expired
+ // can still be REFRESHED — that is the one thing an expired vtxo can
+ // still do — and excluding it here meant a wallet holding nothing else
+ // did nothing at all: no renewal attempt, and no warning either.
+ const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable');
if (!spendables.length) { arkRenewWarn = null; return; }
+ // Register the server-side backstop for anything not covered yet. Ahead
+ // of the in-flight guard below on purpose: a standing order takes no coin
+ // and starts no round, so a send in progress is no reason to skip the one
+ // renewal that survives the app never being opened again. Stamped on its
+ // own clock — the guard below can return before the refresh throttle is
+ // stamped, and this must not then run on every five-second sync.
+ if (Date.now() - arkBackstopAt > 30 * 60_000) {
+ arkBackstopAt = Date.now();
+ await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {});
+ }
// 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();
@@ -3404,6 +3427,26 @@ export function arkFeature(ctx) {
amount: fmtAmount(arkRenewWarn.sat) + ' ' + unitLabel(),
date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(),
})));
+ // Coins past their expiry. The balance already leaves these out, and
+ // until now it did so in silence — money apparently gone, with the one
+ // page that could bring it back reachable only through an advisory
+ // that most wallets never see (it wants four hops of depth) or a
+ // renewal forecast that only appears when the renewal COSTS something,
+ // which is no longer the normal case.
+ //
+ // An expired vtxo can still be renewed — that is the whole of the fix,
+ // and it is one tap away on the Manage page. What it cannot do is be
+ // spent over Ark or exited unilaterally, so saying nothing is the one
+ // option that isn't honest.
+ if (!wallet.watchOnly && ark && ark.balance) {
+ let expiredSat = 0;
+ try { expiredSat = ark.balance().expiredSat || 0; } catch {}
+ if (expiredSat > 0) out.push(h('div', { class: 'notice err', style: 'margin:12px 0 0' },
+ t('arkExpiredNotice', { amount: fmtAmount(expiredSat) + ' ' + unitLabel() }),
+ ' ',
+ h('button', { class: 'linklike small', onClick: () => { ui.arkCoinsPage = true; render(); } },
+ t('arkDepthBtn'))));
+ }
const depthNotice = exitDepthNotice();
if (depthNotice) out.push(depthNotice);
// An upcoming renewal that will COST something — stated before it