diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 32 | ||||
| -rw-r--r-- | src/ark/manager.js | 100 | ||||
| -rw-r--r-- | src/ark/proto.js | 4 | ||||
| -rw-r--r-- | src/features/ark.js | 181 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/nwc-respond.js | 8 | ||||
| -rw-r--r-- | src/sw-nwc.js | 32 |
7 files changed, 313 insertions, 51 deletions
@@ -585,6 +585,15 @@ let _publicChatMode = false; // welcome screen is the one screen that must never be guessed at. The boot // shell holds the frame for those few ms instead; see the dispatch below. let _bootDeciding = true; +// Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page +// starts): the app notes the phase it reaches, and clears the count once it +// has lived eight seconds or leaves cleanly. A page iOS kills while opening +// does neither, so the third start in a row opens in safe mode. +export function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} } +function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} } +export const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); +document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); }); +addEventListener('pagehide', bootSettled); // Belt and braces: a boot path that returns early (or throws) must not leave // the welcome screen held back forever. Nothing legitimate is still deciding // four seconds in — the store wait alone caps at 800ms. @@ -4478,6 +4487,7 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + safeBootNotice(), backupNotice(), ...featureAll('walletNotices'), tabsBar(), @@ -4536,6 +4546,24 @@ function backupNotice() { } }, t('backupReminderGo'))); } +// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a +// row, so this boot made no automatic connections. Say so, name the phase +// the last boot reached, and offer the way back. +function safeBootNotice() { + if (!safeBoot()) return null; + const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?'; + const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'), + 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n'); + return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' }, + h('b', {}, t('safeBootTitle')), + h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })), + h('div', { class: 'row', style: 'gap:8px' }, + h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')), + h('button', { class: 'btn-sm', onClick: (e) => { + navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {}); + } }, t('safeBootCopy')))); +} + function offlineBanner() { return h( 'div', @@ -6824,6 +6852,7 @@ window.addEventListener('load', () => { }, 1000); }); loadLocale(getLang()).finally(async () => { + bootPhase('boot'); applyBootAutoLogout(); // clear an overdue session before we read it for claim targets // Public read-only community chat: no wallet, no login — the join material // ships in the bundle, so the page decrypts client-side (src/public-chat.js). @@ -6849,6 +6878,7 @@ loadLocale(getLang()).finally(async () => { // and a wallet opening in fiat should open with a number in it. refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000); + bootPhase('store'); const storeReady = featureHook('arkStoreReady'); // the IndexedDB caches (profiles, reports, …) open alongside: a wallet's // caches are read as soon as it opens @@ -6859,6 +6889,8 @@ loadLocale(getLang()).finally(async () => { _bootDeciding = false; // from here on the screen is a decision, not a guess const opened = restoreAccountsState(); if (!opened) render(); + bootPhase('ui'); + setTimeout(bootSettled, 8000); if (WAKE_PAYLOAD != null) { // Nothing opened — a locked vault, or no wallet on this device. There is // nothing to answer with, so say so now. diff --git a/src/ark/manager.js b/src/ark/manager.js index df199af..caece44 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -119,6 +119,24 @@ export function lnFoldedIds(movements) { } if (best) out.add(best.id); } + // A Lightning payment the closed app's worker made is told twice once the + // app opens: the worker's own ln-send row (merged in from the mirror) and + // the "while closed" note the app adds from the worker's spend log. Per + // preimage (or payment hash), keep the row that names its inputs — the + // manager's — else the earliest. + const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash)); + const keyOf = new Map(); // payment hash → the preimage key, where a row knows both + for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage); + const byPay = new Map(); + for (const s of sends) { + const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash); + (byPay.get(k) || byPay.set(k, []).get(k)).push(s); + } + for (const rows of byPay.values()) { + if (rows.length < 2) continue; + const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b)); + for (const r of rows) if (r !== keep) out.add(r.id); + } return out; } @@ -208,8 +226,12 @@ export function renewPolicy(tier, network) { } export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) { this.account = account; // HDKey node; ark keys derived beneath it + // The server's last published info, kept by the app: lets the wallet open + // when the server can't be reached (see init) + this.infoHint = infoHint || null; + this.offline = false; // the server cosigns spends of a coin past its timelock (coinos): such a // coin stays money here — spendable, receivable — just not exitable this.honorsExpired = !!honorsExpired; @@ -275,7 +297,19 @@ export class ArkManager { seen.add(m.vtxoId); return true; }); - this.info = await getArkInfo(this.arkUrl); + try { + this.info = await getArkInfo(this.arkUrl); + this.offline = false; + } catch (e) { + // The server is unreachable. With its last published info in hand the + // wallet still opens on its own records: coins read, a unilateral exit + // can start (that needs the chain, not the server), and anything that + // does need the server fails when it asks. Without that info there is + // no server key to check anything against — then it really is closed. + if (!this.infoHint) throw e; + this.info = this.infoHint; + this.offline = true; + } const hs = await handshake(this.arkUrl).catch(() => null); this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); @@ -497,6 +531,32 @@ export class ArkManager { // free to spend, renew or exit from THIS device _free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id) && this._policyType(v) === 'pubkey'; } + // A coin's exit depth: one transaction per hop of its history (the round + // tree's branch, then every arkoor transfer). Each off-chain payment adds + // one to the change and one to what the recipient gets; only a round + // flattens it. The server refuses to cosign a spend of a coin at its + // `maxVtxoExitDepth` (100 on coinos: a wallet paying hourly from one change + // chain gets there in four days), so such a coin is money only a refresh + // can free — and it refreshes without the base fee at `deepWaiverDepth`. + exitDepth(v) { try { return this._decoded(v).genesis.length; } catch { return 0; } } + _capDepth() { return (this.info && this.info.maxVtxoExitDepth) || 0; } + _waiverDepth() { return (this.info && this.info.refreshFees && this.info.refreshFees.deepWaiverDepth) || 0; } + _capped(v) { const cap = this._capDepth(); return !!cap && this.exitDepth(v) >= cap; } + // The free coins a refresh should flatten now: at the server's waiver depth + // (no base fee, so always worth it), or within a few hops of the cap on a + // server without one (stuck otherwise). Expired coins too: a round is the + // one thing an expired coin can always do, and the server honors it. + deepCoins() { + const waiver = this._waiverDepth(), cap = this._capDepth(); + if (!waiver && !cap) return []; + const busy = this._remoteBusy(); + return this.state.vtxos.filter((v) => { + if (!this._free(v, busy)) return false; + const d = this.exitDepth(v); + return (waiver && d >= waiver) || (cap && d >= cap - 8); + }); + } + balance() { const tip = this._tipH || 0; const busy = this._remoteBusy(); @@ -1087,8 +1147,9 @@ export class ArkManager { _selectInputs(amountSat, tip = this._tipH || 0) { const busy = this._remoteBusy(); + // a coin at the server's depth cap can't be an input: the cosign is refused const spendable = this.state.vtxos - .filter((v) => this._free(v, busy) && !this._expired(v, tip)); + .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v)); const single = this._spendOrder(spendable.filter((v) => v.amountSat >= amountSat), tip, (a, b) => a.amountSat - b.amountSat)[0]; if (single) return [single]; const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat); @@ -1106,6 +1167,8 @@ export class ArkManager { .filter((v) => v.state === 'spendable' && this._expired(v, tip)) .reduce((n, v) => n + v.amountSat, 0); if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`; + const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0); + if (cappedSat) return `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`; // coins a renewal took stay unspendable until its round confirms on-chain const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat; return renewing @@ -2228,7 +2291,10 @@ export class ArkManager { const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop(); ppmUnits += v.amountSat * (entry?.ppm ?? 0); } - return this.info.refreshFees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000); + // the server waives the base fee when every input is at its waiver depth + const waiver = this._waiverDepth(); + const base = waiver && inputs.length && inputs.every((v) => this.exitDepth(v) >= waiver) ? 0 : this.info.refreshFees.baseFeeSat; + return base + Math.ceil(ppmUnits / 1_000_000); } // manual: the user asked for this one (the coins page) — it earns a @@ -2591,6 +2657,32 @@ export class ArkManager { return n; } + // The worker's version of flattening deep coins: it can't sit through a + // live round, so each deep coin gets a standing order for two blocks out, + // one per coin (an order dies whole when any input is spent — and a coin + // that is spent hourly will void it; the next wake registers again, and + // the one time the coin rests through the height, the round runs). Only + // coins still inside their timelock can be scheduled; an expired deep coin + // waits for the app, whose live refresh the server honors. + async ensureDeepRefreshes(maxCoins = 2) { + const deep = this.deepCoins(); + if (!deep.length || !maxCoins) return 0; + const tip = await this._tipMemo(); + if (!tip) return 0; + const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); + const cap = this.info.maxVtxoAmountSat || Infinity; + const coins = deep + .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap) + .sort((a, b) => this.exitDepth(b) - this.exitDepth(a)) + .slice(0, maxCoins); + let n = 0; + for (const v of coins) { + try { await this.scheduleRefresh([v.id], tip + 2); n++; } + catch (e) { console.warn('ark: no deep refresh order for', v.id, '—', e.message); } + } + return n; + } + _dropScheduled(entry) { this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); this._save(); diff --git a/src/ark/proto.js b/src/ark/proto.js index eb89d5e..849aaac 100644 --- a/src/ark/proto.js +++ b/src/ark/proto.js @@ -416,6 +416,7 @@ export async function getArkInfo(ark) { if (field === 13) info.minBoardAmountSat = Number(value); if (field === 15) info.lnReceiveAntiDosRequired = Number(value) !== 0; if (field === 16) info.mailboxPubkey = hex.encode(value); + if (field === 19) info.maxVtxoExitDepth = Number(value); // past this depth a coin can't be spent until refreshed if (field === 20) info.maxOffboardInputs = Number(value); if (field === 18) { // FeeSchedule for (const f of pbFields(value)) { @@ -445,9 +446,10 @@ export async function getArkInfo(ark) { info.offboardFees = ob; } if (f.field === 3) { // refresh - const rf = { baseFeeSat: 0, ppmExpiryTable: [] }; + const rf = { baseFeeSat: 0, ppmExpiryTable: [], deepWaiverDepth: 0 }; for (const g of pbFields(f.value)) { if (g.field === 1) rf.baseFeeSat = Number(g.value); + if (g.field === 3) rf.deepWaiverDepth = Number(g.value); // base fee waived when every input is this deep if (g.field === 2) { const e = { thresholdBlocks: 0, ppm: 0 }; for (const h of pbFields(g.value)) { diff --git a/src/features/ark.js b/src/features/ark.js index 6952fc0..739dd5e 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'; @@ -494,6 +499,7 @@ export function arkFeature(ctx) { } let arkTimer = null; let arkConnectPromise = null; + let arkInitPromise = null; // the manager's own init, apart from the snapshot gate let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch function stopArk() { @@ -514,6 +520,7 @@ export function arkFeature(ctx) { ark = null; arkInited = null; arkConnectPromise = null; + arkInitPromise = 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 // wallet's amount and deadline, over the new wallet's balance — and the @@ -545,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(() => {}); } } @@ -594,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. @@ -663,6 +672,9 @@ export function arkFeature(ctx) { esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired, + // the server's last published info: the wallet opens on it when the + // server is gone, so coins can still be read and exited + infoHint: (cachedArkInfo(cfg.ark) || {}).info || null, // the wallet's own tx table as a second witness for confirmations — // its data source is user-configurable and often healthier than the // ark preset's esplora from this device @@ -676,12 +688,15 @@ 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 - rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); + if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); render(); }); + arkInitPromise = inited; // The tip: expiries and exit costs are judged against it, and init alone // never asks for it (the first sync does, after the snapshot gate). Ask // now, off the connect path, and remember it for the next refresh. @@ -693,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) @@ -733,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) => { @@ -1012,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])); @@ -1021,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: @@ -2202,23 +2224,8 @@ export function arkFeature(ctx) { const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address)); // Offboarding moves whole vtxos, so carve out the exact amount first // when we're not sending everything. - const spendables = () => freeCoins(mgr.state); - const total = spendables().reduce((n, v) => n + v.amountSat, 0); - let ids; - if (amountSat < total) { - let exact = spendables().find((v) => v.amountSat === amountSat); - if (!exact) { - const before = new Set(spendables().map((v) => v.id)); - await mgr.send(mgr.address(), amountSat); - for (let i = 0; i < 30 && !exact; i++) { - await new Promise((r) => setTimeout(r, 2000)); - await mgr.sync().catch(() => {}); - exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); - } - if (!exact) throw new Error(t('arkSplitTimeout')); - } - ids = [exact.id]; - } + const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0); + const ids = amountSat < total ? await carveCoins(mgr, amountSat) : undefined; await mgr.startOffboard(spk, target.address, ids); return; } @@ -2670,7 +2677,7 @@ export function arkFeature(ctx) { 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 cur = getCustody(); // never 'custodial' on a server that doesn't honor expired coins 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; @@ -2938,7 +2945,17 @@ export function arkFeature(ctx) { async function doArkExit(ids = null) { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { - const mgr = await connectArk(); + // The manager's own init is enough for an exit — the server may be + // gone (that is what a unilateral exit is for), and the snapshot gate + // the full connect also waits on may never clear without the relays. + let mgr = arkView(); + if (!mgr) { + connectArk().catch(() => {}); + for (let i = 0; i < 50 && !arkInitPromise; i++) await new Promise((r) => setTimeout(r, 100)); + try { await arkInitPromise; } catch {} + mgr = arkView(); + } + if (!mgr) throw new Error(t('arkNotConnected')); const spendables = exitableCoins(mgr.state, mgr._tipH || 0).filter((v) => !ids || ids.includes(v.id)); if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected')); // Fees are paid hop by hop from Savings as the exit unrolls — an exit @@ -3284,6 +3301,58 @@ export function arkFeature(ctx) { })()); } + // A set of coins adding up to exactly `amountSat`: one coin of that size, + // else the smallest subset of the free coins that sums to it (largest coins + // first, so an offboard takes as few inputs as it can). null when no subset + // fits. Reachable sums are built up coin by coin, capped so a balance in + // hundreds of dust coins can't stall the tap. + function exactCoins(coins, amountSat) { + const one = coins.find((v) => v.amountSat === amountSat); + if (one) return [one]; + const sorted = [...coins].filter((v) => v.amountSat > 0 && v.amountSat < amountSat).sort((a, b) => b.amountSat - a.amountSat); + const reach = new Map([[0, null]]); // sum -> { coin, prev } that first reached it + for (const v of sorted) { + const add = []; + for (const sum of reach.keys()) { + const next = sum + v.amountSat; + if (next <= amountSat && !reach.has(next)) add.push([next, { coin: v, prev: sum }]); + } + for (const [sum, via] of add) reach.set(sum, via); + if (reach.has(amountSat)) break; + if (reach.size > 50_000) return null; + } + if (!reach.has(amountSat)) return null; + const picked = []; + for (let sum = amountSat; sum > 0;) { const via = reach.get(sum); picked.push(via.coin); sum = via.prev; } + return picked; + } + // The coins that leave Spending for an on-chain send of `amountSat`. An + // offboard moves whole vtxos, so the amount has to be covered exactly: + // by coins already in the wallet when they fit (a balance that happens to + // add up, or the halves an earlier split left behind), else an exact amount + // is split off by paying it to this wallet's own address. A payment spread + // over several inputs arrives as several coins, one per input with the last + // carrying the remainder, so what the split yields is every coin that lands + // on the receive key and adds up to the amount, not a single coin of that + // size: looking for one made every partial on-chain send from a balance + // in two or more coins time out (Jarryd, 2026-10-23, three splits and no + // send). Change from the split carries its own key index and stays. + async function carveCoins(mgr, amountSat) { + const free = () => freeCoins(mgr.state); + const have = exactCoins(free(), amountSat); + if (have) return have.map((v) => v.id); + const before = new Set(free().map((v) => v.id)); + await mgr.send(mgr.address(), amountSat); + for (let i = 0; i < 30; i++) { + await mgr.sync().catch(() => {}); + const landed = free().filter((v) => !before.has(v.id) && !v.keyIndex); + const hit = exactCoins(landed, amountSat); + if (hit) return hit.map((v) => v.id); + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error(t('arkSplitTimeout')); + } + // vtxoIds: exactly these coins go (the coins page's ticked subset); // otherwise an amount is split off the balance, or everything goes. async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) { @@ -3292,23 +3361,9 @@ export function arkFeature(ctx) { const address = destAddress || wallet.freshReceive().address; const spk = addrScript(address); const mgr = await connectArk(); - const spendables = () => freeCoins(mgr.state); - const total = spendables().reduce((n, v) => n + v.amountSat, 0); + const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0); let ids = vtxoIds; // undefined = all spendable vtxos - if (!ids && amountSat && amountSat < total) { - let target = spendables().find((v) => v.amountSat === amountSat); - if (!target) { - const before = new Set(spendables().map((v) => v.id)); - await mgr.send(mgr.address(), amountSat); - for (let i = 0; i < 30 && !target; i++) { - await new Promise((r) => setTimeout(r, 2000)); - await mgr.sync().catch(() => {}); - target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); - } - if (!target) throw new Error(t('arkSplitTimeout')); - } - ids = [target.id]; - } + if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat); const action = await mgr.startOffboard(spk, address, ids); ui.arkOffboardAmt = ''; ui.arkMoveOpen = false; @@ -3347,10 +3402,20 @@ export function arkFeature(ctx) { // happening anyway, for next to nothing; the Manage page consolidates on // request. const CUSTODY_DEFAULT = 'custodial'; + // The tier in force: the stored choice, except that "coinos holds my coins" + // exists only on a server that honors a coin past its timelock. Anywhere + // else (Second, a custom captaind) an expired coin is gone, so renewal is + // automatic there whatever is stored — the stored default stays on the + // record for a later switch back to a coinos server. From 2026-10-22 to + // 10-23 the stored default applied everywhere, and a wallet on such a + // server renewed nothing and warned of nothing, in the app and the worker. function getCustody() { let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null; if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026) - return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + const tier = CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + if (tier !== 'custodial') return tier; + const cfg = getArkConfig(); + return cfg && !cfg.honorsExpired ? 'self' : tier; } function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); } const custodyPolicy = () => renewPolicy(getCustody(), getNetwork()); @@ -3361,8 +3426,10 @@ export function arkFeature(ctx) { // (there is no cancel; a spent input voids one) — they were priced when // registered and renew one coin each, once. function applyCustody(tier) { - if (!CUSTODY_TIERS.includes(tier) || tier === getCustody()) return; - setCustody(tier); + if (!CUSTODY_TIERS.includes(tier)) return; + const same = tier === getCustody(); + setCustody(tier); // the record keeps the choice even when it is already in force here + if (same) return; arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null; if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {}); writeBgMirror().catch(() => {}); @@ -3399,10 +3466,32 @@ export function arkFeature(ctx) { return true; } + // Whatever the tier, a coin whose history has run very deep is renewed: + // the server stops cosigning spends of a coin at its depth cap, so this is + // what keeps such a coin spendable, and it waives the base fee for a + // refresh of deep coins alone — so they go in a round of their own, never + // mixed with coins that would make it pay. A wallet paying hourly from one + // change chain (a subscription) is the usual way to get here. + async function flattenDeepCoins(mgr, tip) { + const deep = mgr.deepCoins ? mgr.deepCoins() : []; + if (!deep.length) return false; + if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return false; + const feeSat = mgr.refreshFee(deep, tip); + const totalSat = deep.reduce((n, v) => n + v.amountSat, 0); + if (totalSat - feeSat < 330 || feeSat * 10 > totalSat) return false; // dust, or a server charging full price: left alone + await mgr.refresh(deep.map((v) => v.id)); + render(); + return true; + } + async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; const policy = custodyPolicy(); + if (mgr.deepCoins && mgr.deepCoins().length) { + arkAutoRefreshAt = Date.now(); + try { if (await flattenDeepCoins(mgr, await mgr.chain.tipHeight())) return; } catch {} + } // "coinos holds my coins": nothing renews on its own and nothing warns. // The server honors a coin past its timelock, and the holder chose this. if (!policy) { arkRenewWarn = null; return; } @@ -4012,9 +4101,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(); diff --git a/src/i18n.js b/src/i18n.js index edf9102..78ba336 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -783,9 +783,14 @@ const STR = { arkCoopSelectedAmt: '{n} selected coins · {amount}', arkCoopSelectedAmtOne: '1 selected coin · {amount}', arkCustodyTitle: 'Custody strategy', + safeBootTitle: 'Coinos opened in safe mode', + safeBootBody: 'The app was closed {n} times in a row before it finished opening, so automatic connections are paused this time. Your coins and keys are untouched. Last step reached: {phase}.', + safeBootOpen: 'Open normally', + safeBootCopy: 'Copy report', + safeBootCopied: 'Copied', arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos; after that, Coinos keeps honoring it. Renewing before expiry keeps your own exit, for a fee, so it is a choice:', arkCustodyCustodial: 'Allow coins to expire', - arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation.', + arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation. The one exception: a coin whose payment history has grown very long is renewed free of the base fee, so it stays spendable.', arkCustodySelf: 'Minimize renewal cost', arkCustodySelfDesc: 'Coins renew in the last hours before they expire, when renewal is cheapest, so you can always exit on your own without Coinos. Roughly once every {days} days per coin. Coins too small to be worth the fee are left alone.', arkCustodyExit: 'Minimize exit cost', diff --git a/src/nwc-respond.js b/src/nwc-respond.js index e6ee563..10692c9 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -469,7 +469,13 @@ export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++; if (room && policy) { const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; }); - if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); } + if (got) { n += got; room -= got; log(`registered ${got} renewal order(s) while closed`); } + } + // Under every tier: a coin near the server's depth cap must be + // flattened to stay spendable (the base fee is waived for it). + if (room > 0) { + const got = await bounded(mgr.ensureDeepRefreshes(room)).catch((e) => { log('deep refresh order: ' + e.message); return 0; }); + if (got) { n += got; log(`registered ${got} deep-coin renewal order(s) while closed`); } } await saveFn(walletKey, rec); } catch (e) { log('renewals: ' + e.message); } diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 0b65268..211c4f9 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -29,7 +29,7 @@ async function applePush() { } catch (_) { return false; } } async function standIn(visible) { - if (!(await applePush())) return; + if (!(await applePush())) return false; await self.registration.showNotification('Coinos', { body: 'Your wallet checked in.', icon: 'icon-192.png', badge: 'badge-96.png', tag: 'coinos-bg', renotify: false, silent: true, data: { url: './' }, @@ -38,6 +38,7 @@ async function standIn(visible) { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); } + return true; } self.addEventListener('push', (e) => { @@ -45,7 +46,17 @@ self.addEventListener('push', (e) => { try { data = e.data ? e.data.json() : {}; } catch (_) {} // every path below ends in a notification or, failing that, the stand-in let showed = false; - const show = (title, opts) => { showed = true; return self.registration.showNotification(title, opts); }; + // A notification the platform refuses must not count as shown: the old + // code flagged it before the call, so a refusal left the push with no line + // at all — a silent push, on iOS a strike towards losing the subscription. + // (PullCap's night of 2026-10-22: paid four times, no "Payment sent" seen.) + // Try the full options; refused, try again with the plainest ones. + const show = async (title, opts) => { + try { await self.registration.showNotification(title, opts); showed = true; return; } + catch (e) { console.warn('[sw] notification refused:', e && e.message); } + try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; } + catch (e) { console.warn('[sw] plain notification refused too:', e && e.message); } + }; // the stand-in, as soon as a path knows it has nothing to say (a long tail // of work after it mustn't keep the push waiting for its notification) const quiet = async () => { @@ -155,14 +166,25 @@ self.addEventListener('push', (e) => { if (data.type !== 'nwc') { settle(async () => {}); return; } const startedAt = Date.now(); settle(async () => { + // WebKit's silent-push quota counts a push whose notification comes + // late: a payment can take most of the push budget, and a worker stopped + // before its last line never shows one (PullCap's night of 2026-10-22: + // four payments, no line for the fourth, then no more wakes). On an + // Apple endpoint the stand-in goes up FIRST; the outcome replaces it. + const apple = await applePush(); + const dropStandIn = async () => { + const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); + for (const n of open) n.close(); + }; + if (apple && await standIn(false).catch(() => false)) showed = true; // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. On iOS only one on screen counts: a // backgrounded app is suspended, so a nudge to it went unanswered. const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const apple = await applePush(); const clients = all.filter((c) => !apple || c.visibilityState === 'visible'); if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); + if (apple) await dropStandIn(); // the window on screen shows it live return; } // A wake is the only moment a closed wallet gets to look after its coins: @@ -186,9 +208,10 @@ self.addEventListener('push', (e) => { await show(handled.notify.title, { body: handled.notify.body, icon: 'icon-192.png', badge: 'badge-96.png', - tag: handled.notify.tag || 'nwc-incoming', renotify: true, + tag: handled.notify.tag || 'nwc-incoming', renotify: !apple, data: { url: './' }, }); + if (apple) await dropStandIn(); await tend(); return; } @@ -204,6 +227,7 @@ self.addEventListener('push', (e) => { tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); + if (apple) await dropStandIn(); }); }); |
