From 31fea2d1e463424387828163bda9facc2fee8c0d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 27 Sep 2026 15:07:31 -0700 Subject: Three fixes: orphaned pending coins settle with the server; a saved start screen no longer shadows a restored wallet; stop() no longer throws. Reconcile now asks the server about a 'pending' coin no live action or standing renewal order holds: spendable again, or gone. Thirty zap-sized coins another device had already spent counted in a balance for weeks. A nav saved by a page with no wallet (the start screen) was restored over a wallet that had since been saved to the device, painting the start page. messages.stop() referenced feedAt, which no longer exists, so the rest of the teardown never ran on an account switch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D --- src/app.js | 4 ++++ src/ark/manager.js | 24 ++++++++++++++++++++++-- src/features/messages.js | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/app.js b/src/app.js index 1bbbe01..8b93766 100644 --- a/src/app.js +++ b/src/app.js @@ -826,6 +826,10 @@ function restoreNavFromHistory(renderNow = true) { const nav = BOOT_NAV; if (!nav || ui.screen !== 'wallet') return; bootPending = false; + // The page that saved this nav may have had no wallet (the start screen, + // the vault prompt); with an account open now, those screens are stale. + // Restoring one painted the start page over a perfectly good wallet. + if (['unlock', 'vault'].includes(nav.screen) && activeAccount()) nav.screen = 'wallet'; for (const f of NAV_FIELDS) if (f in nav) ui[f] = f === 'profEdit' && nav[f] ? structuredClone(nav[f]) : nav[f]; ui.lightbox = null; nav.lightbox = null; // an object URL doesn't survive a reload navStack = [nav]; diff --git a/src/ark/manager.js b/src/ark/manager.js index d097a44..bfb4a4e 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -21,7 +21,7 @@ import { getArkInfo, handshake, encodeAddress, decodeAddress, blindMailboxId, readMailbox, decodeVtxo, arkIdFromServerPubkey, GrpcError, grpcStream, decodeMailboxMessage, mailboxRequestBytes, - getVtxoStatus, VTXO_STATE_SPENT, vtxoBytesToStr, vtxoBytesFromStr, vtxoBytesNormalize } from './proto.js'; + getVtxoStatus, VTXO_STATE_SPENT, VTXO_STATE_SPENDABLE, vtxoBytesToStr, vtxoBytesFromStr, vtxoBytesNormalize } from './proto.js'; import { buildArkoorSend, cosignWithServer, cosignPackageWithServer, buildAllSignedVtxos, registerVtxoTransactions, postArkoorMessage, txid, @@ -597,7 +597,19 @@ export class ArkManager { // way — spendable -> spent — so a lying server can hide balance from the UI // but never mint it, and the signed bytes stay held for unilateral exit. async reconcile() { - const candidates = this.state.vtxos.filter((v) => v.state === 'spendable'); + // 'pending' means an action of this device holds the coin. One that no + // live action (or standing renewal order) names is an orphan: the pay + // that took it ran on another device and merged in mid-flight, or its + // action was pruned. Left alone it counted in the balance forever (thirty + // zap-sized coins, 645 sats, on adam's laptop); the server settles it. + const held = new Set(); + for (const a of this.state.actions || []) { + if (a.step === 'done' || a.step === 'failed') continue; + for (const id of a.inputIds || []) held.add(id); + for (const part of a.parts || []) if (part && part.inputId) held.add(part.inputId); + } + for (const e of this.state.scheduled || []) for (const id of e.inputIds || []) held.add(id); + const candidates = this.state.vtxos.filter((v) => v.state === 'spendable' || (v.state === 'pending' && !held.has(v.id) && v.bytes)); // Each status query signs a challenge (schnorr) and may decode the vtxo — // real secp256k1 work. Building them all in one synchronous map was a // ~100ms+ EC burst on boot (dozens of coins), the stall behind a carousel @@ -612,6 +624,14 @@ export class ArkManager { const states = await Promise.all(pending); // unreachable/erroring server changes nothing let changed = false; candidates.forEach((v, i) => { + if (v.state === 'pending') { + // an orphan the server still counts is simply spendable again; one it + // holds as spent is gone, and the pay that took it was recorded (or + // will merge in) elsewhere — no row of its own + if (states[i] === VTXO_STATE_SPENDABLE) { v.state = 'spendable'; changed = true; } + else if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; } + return; + } if (states[i] !== VTXO_STATE_SPENT) return; v.state = 'spent'; this._movement({ type: 'reconcile', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, detail: 'spent elsewhere (server vtxo status)' }); diff --git a/src/features/messages.js b/src/features/messages.js index 59fb0a5..a55e735 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8581,7 +8581,7 @@ export function messagesFeature(ctx) { for (const c of feedStates.values()) c.stopped = true; feedStates.clear(); threadCache.clear(); - follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null; + follows = null; followsAt = 0; feed = null; relayLists = null; mutes = null; mutesAt = 0; stCache = null; stAt = 0; // the next account's state, not this one's if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped -- cgit v1.2.3