From d6bc9914368cd1ecfe26e57b5ab5d89e389a3f09 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 22 Oct 2026 22:50:35 -0700 Subject: Spending settings paint their real numbers at once after a refresh: the manager is readable the moment its own init is done, and the server's fee table and tip are cached per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the coins were on screen at 350 ms (from the store), but the renewal price read "free", expiries "—" and exit costs the fallback for eleven seconds, because the pages read everything from `ark`, which is only set once the Nostr snapshot restore has also finished (Promise.all with mgr.init) — a gate that exists so the mailbox isn't replayed before the snapshot's cursor arrives, not for display. GetArkInfo itself answers in 0.4 s. `arkInited` is the same manager as soon as mgr.init() resolves (info, server key, tip, persisted coins); arkView() = ark || arkInited is what the custody card and the coins page read from. Renew (the one action the page runs on the manager directly) still waits for `ark`; exits go through connectArk, which waits anyway. For the first half second before init, btc-wallet-arkinfo: in localStorage carries the last info and tip seen (tip advanced a block per ten minutes), so the first paint prices renewals right and shows days-left; a card with no info at all shows "…", never "free". Co-Authored-By: Claude Fable 5.1 --- src/features/ark.js | 52 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 10 deletions(-) (limited to 'src/features/ark.js') diff --git a/src/features/ark.js b/src/features/ark.js index 1c57cd6..03f41ed 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -460,6 +460,29 @@ export function arkFeature(ctx) { // One manager per open wallet; null when Ark is off in Settings, watch-only, // or not yet dialed (lazy: fresh wallets connect on first use). let ark = null; + // The same manager the moment its own init is done (server info, tip, + // persisted coins) — before the Nostr snapshot restore that `ark` also + // waits for, which can take ten seconds on a slow relay/Blossom day. For + // READING only: the pages price and label coins from it; anything that + // acts (a renewal, an exit) still waits for `ark`. + let arkInited = null; + const arkView = () => ark || arkInited; + // The last server info and chain tip seen, per server, so the first paint + // after a refresh shows the real renewal price and expiries instead of + // "free" and "—" for the half second the info call takes. + const ARK_INFO_KEY = 'btc-wallet-arkinfo:'; + function cachedArkInfo(url) { + try { return url ? JSON.parse(localStorage.getItem(ARK_INFO_KEY + url) || 'null') : null; } catch { return null; } + } + function rememberArkInfo(url, info, tip) { + try { if (url && info) localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify({ info, tip: tip || 0, at: Date.now() })); } catch {} + } + // the tip, live or estimated from the last one seen (a block per ten minutes) + function arkTipNow(mgr) { + if (mgr && mgr._tipH) return mgr._tipH; + const c = cachedArkInfo((getArkConfig() || {}).ark); + return c && c.tip ? c.tip + Math.floor((Date.now() - (c.at || Date.now())) / 600_000) : 0; + } let arkTimer = null; let arkConnectPromise = null; let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch @@ -480,6 +503,7 @@ export function arkFeature(ctx) { clearTimeout(lnTagT); lnTagSeen = -1; ark = null; + arkInited = null; arkConnectPromise = null; // The renewal warning describes the coins of the wallet we're leaving. // Switching accounts used to carry it onto the next one — with the old @@ -643,7 +667,13 @@ export function arkFeature(ctx) { // Open the server connection while Nostr restores, but do not replay the // mailbox before the snapshot's history and cursor have arrived. const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false); - arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => { + const inited = mgr.init().then(() => { + if (gen !== arkInitGen) return; + arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot + rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); + render(); + }); + arkConnectPromise = Promise.all([inited, restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; // Sync may have arrived while init awaited the server handshake. @@ -2309,7 +2339,7 @@ export function arkFeature(ctx) { // page, or a tap before the manager has connected (the first seconds // after boot) found no coins and closed itself — Manage needed two taps. // The manager is dialled meanwhile; what needs it live waits for it. - const mgr = ark; + const mgr = arkView(); const st = (mgr && mgr.state) || arkStateNow(); // another device's renewal holds its coins just the same: greyed here too const elsewhere = st ? remoteRenewalsInFlight(st) : []; @@ -2321,7 +2351,7 @@ export function arkFeature(ctx) { const renewing = ((st && st.actions) || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step)); const inRound = new Set([...renewing, ...elsewhere].flatMap((a) => a.inputIds || [])); const pendingCoins = ((st && st.vtxos) || []).filter((v) => (v.state === 'pending' || v.state === 'spendable') && inRound.has(v.id)); - const tip = (mgr && mgr._tipH) || 0; + const tip = arkTipNow(mgr); // the coinos servers keep honoring a coin past its timelock: it is held // by coinos, not expired — still money, just not exitable on its own const honors = !!(mgr && mgr.honorsExpired); @@ -2365,10 +2395,11 @@ export function arkFeature(ctx) { // bracket the share is zero, where the wallet renews on its own anyway. // A flat base fee (the round's on-chain cost, which coinos stopped // absorbing in October 2026) rides on top of every bracket. - const table = (((mgr && mgr.info) || {}).refreshFees || {}).ppmExpiryTable || []; - const baseSat = (((mgr && mgr.info) || {}).refreshFees || {}).baseFeeSat || 0; + const infoNow = (mgr && mgr.info) || (cachedArkInfo((getArkConfig() || {}).ark) || {}).info || {}; + const table = (infoNow.refreshFees || {}).ppmExpiryTable || []; + const baseSat = (infoNow.refreshFees || {}).baseFeeSat || 0; const baseCost = fmtSats(baseSat) + ' sats'; - const roundMins = Math.max(1, Math.round((((mgr && mgr.info) || {}).roundIntervalSecs || 120) / 60)); + const roundMins = Math.max(1, Math.round((infoNow.roundIntervalSecs || 120) / 60)); const roundEvery = roundMins === 1 ? t('arkCoinsRoundEveryOne') : t('arkCoinsRoundEvery', { n: roundMins }); const ppmFor = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; // A coin's SHARE of the renewal fee, fractional: the server totals the @@ -2435,7 +2466,7 @@ export function arkFeature(ctx) { row(t('arkRenewConfirmLeft'), fmtSats(leftSat) + ' sats', leftSat ? '' : 'err')), leftSat ? null : h('div', { class: 'notice err small' }, t('arkRenewConfirmAll')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmWait')), - h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !mgr, onClick: renew }, + h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !ark, onClick: renew }, selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkRenewConfirm = null; render(); } }, t('cancel'))); } @@ -2573,7 +2604,7 @@ export function arkFeature(ctx) { ...schedule.map((r) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, r.label), h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null, - !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !mgr, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } }, + !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } }, !selCoins.length ? t('arkCoinsRenewNone') : selCoins.length < spend.length ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') }) @@ -2617,14 +2648,15 @@ export function arkFeature(ctx) { function custodyCard() { if (!arkAvailable() || wallet.watchOnly) return null; const cfg = getArkConfig(); - const info = (ark && ark.info) || null; + const view = arkView(); + const info = (view && view.info) || (cachedArkInfo(cfg && cfg.ark) || {}).info || null; const honors = !!(cfg && cfg.honorsExpired); const cur = honors ? getCustody() : 'self'; const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0; const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || []; const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144)); - const baseCost = base ? fmtSats(base) + ' sats' : t('arkDepthFree'); + const baseCost = !info ? '…' : base ? fmtSats(base) + ' sats' : t('arkDepthFree'); // an early renewal pays the bracket a fresh coin sits in (the top one) const earlyPct = pct(ppmAt(((info && info.vtxoExpiryDelta) || 8640) - 1)); const hops = (renewPolicy('exit', getNetwork()) || {}).maxDepth || 4; -- cgit v1.2.3