diff options
| author | Adam Soltys <[email protected]> | 2026-10-23 07:39:05 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-23 07:39:05 -0700 |
| commit | b3cee21d7f6614c4ec0cf462ba4cfe3a3d4e308c (patch) | |
| tree | 6a509af445ba45412e4bcf06c5c06ecbb45c6ccc /src/features | |
| parent | c143b7dbe900df9a644bb67b87a92fd208e1ee74 (diff) | |
| download | coinosv3-b3cee21d7f6614c4ec0cf462ba4cfe3a3d4e308c.tar.gz coinosv3-b3cee21d7f6614c4ec0cf462ba4cfe3a3d4e308c.zip | |
iPhone: a push shows its line first and the outcome replaces it; a crash loop on open lands in safe mode; a closed-app payment is one history row
PullCap's night of 2026-10-22 on an iPhone: four hourly payments made while
closed, no notification for the fourth, then no more wake-ups until the app
was opened — WebKit's silent-push quota. APNs accepted every push (no 410);
the device had stopped listening. The stand-in line went up at the END of
the push handler, after a payment that can take most of the budget, and a
worker stopped before its last line showed nothing. On an Apple endpoint the
line now goes up first and the outcome ("Payment sent") replaces it.
The same phone then crashed on every open ("A problem repeatedly occurred").
An inline guard counts page starts before the app runs; the app clears the
count after eight seconds alive or a clean leave, and notes the boot phase
it reaches. A third start in a row opens in safe mode: no automatic Ark
connection, a notice naming the last phase, "Open normally" and "Copy
report".
Every payment the worker made showed twice once the app opened: the worker's
own ln-send row (merged from the mirror) and the "while closed" note the app
adds from its spend log. The note now stamps the existing row, and
lnFoldedIds hides a twin that still forms. The balance was never counted
twice — it comes from the coins.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 30 |
1 files changed, 29 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 94dfc09..5a8dc61 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -24,6 +24,11 @@ function freeCoins(st) { function exitableCoins(st, tip) { return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip)); } +// The crash-loop guard's phase notes (build.js BOOT_GUARD, app.js bootPhase): +// a boot iOS killed shows the last one it reached, and three in a row open +// the app in safe mode, where nothing connects on its own. +const bootPhase = (p) => { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }; +const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js'; @@ -547,9 +552,11 @@ export function arkFeature(ctx) { // holds is on screen the moment it can be. const store = wallet._arkStore; if (store && !store.ready) { + bootPhase('ark-store'); store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } + if (safeBoot()) return; // safe mode: the coins show from the store; nothing dials out unasked if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().then(() => rescueOtherServers()).catch(() => {}); } } @@ -596,7 +603,7 @@ export function arkFeature(ctx) { } } function maybeInitArk() { - if (ark || arkConnectPromise) return; + if (ark || arkConnectPromise || safeBoot()) return; if (Date.now() - lastAutoInit < 20000) return; // A fresh snapshot may be held under its server pubkey, so arkWanted() // cannot see it until the handshake identifies the server and adopts it. @@ -681,8 +688,10 @@ 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); + bootPhase('ark-init'); const inited = mgr.init().then(() => { if (gen !== arkInitGen) return; + bootPhase('ark-inited'); arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); render(); @@ -699,6 +708,7 @@ export function arkFeature(ctx) { arkConnectPromise = Promise.all([inited, restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; + bootPhase('ark-adopt'); // Sync may have arrived while init awaited the server handshake. adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark)); // Boards that died before funding (a failed tx build, a closed tab) @@ -739,12 +749,14 @@ export function arkFeature(ctx) { // sync() already reconciles on its first pass. Start the live stream // afterwards, at the caught-up cursor: running both together replayed // the backlog one message/render at a time alongside the batch read. + bootPhase('ark-sync'); tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); }); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); + bootPhase('ark-ready'); render(); return mgr; }).catch((e) => { @@ -1018,6 +1030,7 @@ export function arkFeature(ctx) { return; } if (!rec.mgr) return; + bootPhase('ark-merge'); const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1027,8 +1040,11 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); + bootPhase('ark-reconciled'); await mgr.resumePending().catch(() => {}); + bootPhase('ark-resumed'); } // A leftover chain-6 pouch from before whole-balance background spending: @@ -4028,9 +4044,21 @@ export function arkFeature(ctx) { async arkBgNoteSpends(spends) { const mgr = await connectArk(); for (const sp of spends) { + // The worker's own manager wrote this payment's row into the mirror, + // and the merge on connect brings it over: a second row here showed + // every closed-app payment twice (PullCap, 2026-10-23). Stamp the + // row that exists instead; lnFoldedIds hides a twin that still + // forms when the merge lands after this note. + const twin = (mgr.state.movements || []).find((m) => m.type === 'ln-send' + && ((sp.preimage && m.preimage === sp.preimage) || (sp.paymentHash && m.paymentHash === sp.paymentHash))); + if (twin) { + if (!/while closed/.test(twin.detail || '')) twin.detail = `while closed · fee ${sp.feeSat || 0} sat`; + continue; + } mgr._movement({ type: 'ln-send', amountSat: sp.amountSat, status: 'complete', detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '', + paymentHash: sp.paymentHash || undefined, }); } mgr._save(); |
