aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js32
-rw-r--r--src/ark/manager.js100
-rw-r--r--src/ark/proto.js4
-rw-r--r--src/features/ark.js181
-rw-r--r--src/i18n.js7
-rw-r--r--src/nwc-respond.js8
-rw-r--r--src/sw-nwc.js32
7 files changed, 313 insertions, 51 deletions
diff --git a/src/app.js b/src/app.js
index eb51e2f..ffe9ba8 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();
});
});