aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-27 15:07:31 -0700
committerAdam Soltys <[email protected]>2026-09-27 15:07:31 -0700
commit31fea2d1e463424387828163bda9facc2fee8c0d (patch)
tree9af8a89cbc44519d5a37989f402381381a056748
parent9fdbc82fecf17cefed08e8fe1cfee03546d41c5e (diff)
downloadcoinosv3-31fea2d1e463424387828163bda9facc2fee8c0d.tar.gz
coinosv3-31fea2d1e463424387828163bda9facc2fee8c0d.zip
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
-rw-r--r--src/app.js4
-rw-r--r--src/ark/manager.js24
-rw-r--r--src/features/messages.js2
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