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.js111
-rw-r--r--src/ark/proto.js4
-rw-r--r--src/features/ark.js312
-rw-r--r--src/i18n.js19
-rw-r--r--src/nwc-respond.js8
-rw-r--r--src/sw-nwc.js32
7 files changed, 398 insertions, 120 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 79fdcd0..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();
@@ -825,8 +885,13 @@ export class ArkManager {
v.state = owner.step === 'done' || owner.step === 'failed' ? 'spent' : 'pending';
changed = true;
}
+ // An HTLC coin whose payment no action names any more (done ln-pays are
+ // pruned after a while) is an orphan too: five 22-sat ones sat 'pending'
+ // in a wallet for days, counted as money on its way. The server's answer
+ // settles it — spent is spent; one still open stays pending, since an
+ // HTLC coin is never this wallet's to spend (see below).
const candidates = this.state.vtxos.filter((v) => v.state === 'spendable'
- || (v.state === 'pending' && !held.has(v.id) && v.bytes && this._policyType(v) === 'pubkey'));
+ || (v.state === 'pending' && !held.has(v.id) && v.bytes));
const was = candidates.map((v) => v.state); // what each was when this pass began
// Each status query signs a challenge (schnorr) and may decode the vtxo —
// real secp256k1 work. Building them all in one synchronous map was a
@@ -851,8 +916,8 @@ export class ArkManager {
// 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; }
+ if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ else if (states[i] === VTXO_STATE_SPENDABLE && this._policyType(v) === 'pubkey') { v.state = 'spendable'; changed = true; }
return;
}
if (states[i] !== VTXO_STATE_SPENT) return;
@@ -1082,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);
@@ -1101,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
@@ -2223,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
@@ -2586,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 4b15099..eccc3b8 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';
@@ -38,7 +43,7 @@ import { t } from '../i18n.js';
import { rateNow } from '../rates.js';
import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js';
import { parseZapTarget, fetchPayParams, requestInvoice } from '../lnurl.js';
-import { shortAddr, shortTxid, timeAgo, fmtSats, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js';
+import { shortAddr, shortTxid, timeAgo, fmtSats, fmtBtc, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js';
// t?ark1… bech32m — an Ark address for this or another ASP.
export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); }
@@ -467,6 +472,10 @@ export function arkFeature(ctx) {
// acts (a renewal, an exit) still waits for `ark`.
let arkInited = null;
const arkView = () => ark || arkInited;
+ // An amount in bitcoin terms whatever the display unit: sats, or BTC when
+ // that is the unit chosen — never fiat. A co-operative exit moves coins,
+ // and the number on its button is the coins' own.
+ const fmtNative = (sat) => (unitLabel() === 'BTC' ? fmtBtc(sat) + ' BTC' : fmtSats(sat) + ' sats');
// The last server info and chain tip seen, per server, so the first paint
// after a refresh shows the real renewal price and expiries instead of
// "free" and "—" for the half second the info call takes.
@@ -475,7 +484,12 @@ export function arkFeature(ctx) {
try { return url ? JSON.parse(localStorage.getItem(ARK_INFO_KEY + url) || 'null') : null; } catch { return null; }
}
function rememberArkInfo(url, info, tip) {
- try { if (url && info) localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify({ info, tip: tip || 0, at: Date.now() })); } catch {}
+ try {
+ if (!url || !info) return;
+ // no tip yet (right after init): keep the last one seen, dated as it was
+ const prev = tip ? null : cachedArkInfo(url);
+ localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() }));
+ } catch {}
}
// the tip, live or estimated from the last one seen (a block per ten minutes)
function arkTipNow(mgr) {
@@ -485,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() {
@@ -505,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
@@ -536,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(() => {}); }
}
@@ -585,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.
@@ -654,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
@@ -667,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.
@@ -684,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)
@@ -724,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) => {
@@ -1003,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]));
@@ -1012,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:
@@ -2193,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;
}
@@ -2445,6 +2461,12 @@ export function arkFeature(ctx) {
for (const id of ui.arkCoinsSel) if (!ids.has(id)) ui.arkCoinsSel.delete(id);
const sel = ui.arkCoinsSel;
const selCoins = spend.filter((v) => sel.has(v.id));
+ // the three actions below the table act on the ticked coins
+ const selExitable = selCoins.filter((v) => exitable.includes(v));
+ const selExitSat = selExitable.reduce((n, v) => n + v.amountSat, 0);
+ let selExitFee = 0; try { if (mgr) selExitFee = estimateExitFeeSat(mgr, selExitable); } catch {}
+ const subset = selCoins.length > 0 && selCoins.length < spend.length;
+ const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat;
let selFee = 0; try { selFee = mgr.refreshFee(selCoins, tip); } catch {}
const renew = () => {
if (!selCoins.length || !mgr) return;
@@ -2455,6 +2477,20 @@ export function arkFeature(ctx) {
ui.arkRenewConfirm = null;
render(); // the page stays: the coins show as renewing
};
+ // What a renewal is and what it costs, told where it is decided: on the
+ // confirmation, not as a card on the page (the page is the coins and
+ // their three buttons; the explainers were a wall under them).
+ const renewExplainer = () => [
+ h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors
+ ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') })
+ : baseSat
+ ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost })
+ : t('arkCoinsRenewDesc', { every: roundEvery })),
+ schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' },
+ ...schedule.map((r) => h('div', { class: 'row between' },
+ h('span', { class: 'small muted' }, r.label),
+ h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null,
+ ];
// "Renew" asks once, with the numbers on the table: the coins it takes
// can't be spent until the round's transaction confirms, it can't be
// called back, and by default it takes every coin. (A user renewed their
@@ -2467,6 +2503,7 @@ export function arkFeature(ctx) {
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkRenewConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmBody')),
+ ...renewExplainer(),
h('div', { class: 'col', style: 'gap:4px' },
row(t('arkExitConfirmCoins'), String(selCoins.length)),
row(t('arkExitConfirmAmount'), fmtSats(selSat) + ' sats'),
@@ -2507,22 +2544,23 @@ export function arkFeature(ctx) {
// is published: an exit cannot be called back.
if (ui.arkExitConfirm) {
const savings = wallet.spendable || 0;
- const short = savings < exitFee;
+ const short = savings < selExitFee;
const row = (k, v, cls = '') => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' + (cls ? ' ' + cls : '') }, v));
return h('div', { class: 'col', style: 'gap:16px' },
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(selExitFee), after: fmtSats(afterFee) })),
h('div', { class: 'col', style: 'gap:4px' },
- row(t('arkExitConfirmCoins'), String(exitable.length)),
- row(t('arkExitConfirmAmount'), fmtSats(exitable.reduce((n, v) => n + v.amountSat, 0)) + ' sats'),
- row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'),
+ row(t('arkExitConfirmCoins'), String(selExitable.length)),
+ row(t('arkExitConfirmAmount'), fmtSats(selExitSat) + ' sats'),
+ row(t('arkExitConfirmFee'), fmtSats(selExitFee) + ' sats'),
row(t('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')),
- short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), have: fmtSats(savings) })) : null,
+ short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(selExitFee), have: fmtSats(savings) })) : null,
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmWait')),
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !exitable.length, onClick: () => {
+ h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !selExitable.length, onClick: () => {
ui.arkExitConfirm = null;
- doArkExit();
+ doArkExit(selExitable.map((v) => v.id));
ui.arkCoinsSel = null;
back();
ui.tab = 'history'; // the exit lives as a pending record there
@@ -2597,37 +2635,19 @@ export function arkFeature(ctx) {
h('td', { class: 'num' }, fmtSats(v.amountSat), h('span', { class: 'small faint' }, ' sats')),
h('td', { class: 'num' }, expiresOf(v)),
h('td', { class: 'num' }, '—'),
- h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing'))))))))),
- renewingCard,
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')),
- // the round cadence comes from the server (it has been an hour and
- // is two minutes as of September 2026) — never a number baked in here
- h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors
- ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') })
- : baseSat
- ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost })
- : t('arkCoinsRenewDesc', { every: roundEvery })),
- schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' },
- ...schedule.map((r) => h('div', { class: 'row between' },
- h('span', { class: 'small muted' }, r.label),
- h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null,
- !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } },
- !selCoins.length ? t('arkCoinsRenewNone')
- : selCoins.length < spend.length
- ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
- : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
- // The ticked coins are what leaves: a subset goes on-chain as exactly
- // those coins (no fresh coin split off the whole balance first), all
- // of them — or nothing ticked — means the whole balance as before.
- (() => {
- const subset = selCoins.length > 0 && selCoins.length < spend.length;
- const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat;
- return h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
- subset ? h('p', { class: 'small muted', style: 'margin:0' }, selCoins.length === 1 ? t('arkCoopSelectedOne') : t('arkCoopSelected', { n: selCoins.length })) : null,
- h('button', { class: 'btn-primary btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || wallet.watchOnly, onClick: () => {
+ h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing')))))))),
+ // What the ticked coins can do, right under them: renew, withdraw
+ // (the co-operative exit, into Savings) or exit on their own. Each
+ // names the ticked total it acts on.
+ // present even while every coin is in a round: disabled, so the page
+ // keeps its shape and says why nothing can be done
+ h('div', { class: 'col ark-coin-actions', style: 'gap:6px;margin-top:4px' },
+ h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } },
+ !selCoins.length ? t('arkCoinsRenewNone')
+ : selCoins.length < spend.length
+ ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
+ : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn')),
+ h('button', { class: 'btn-ghost btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || !selCoins.length || wallet.watchOnly, onClick: () => {
const address = wallet.freshReceive().address;
const vtxoIds = subset ? selCoins.map((v) => v.id) : undefined;
ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null;
@@ -2635,13 +2655,11 @@ export function arkFeature(ctx) {
ctx.showSend({ fresh: true });
ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds };
render();
- } }, t('arkOffboardBtn', { n: fmtAmount(coopSat) + ' ' + unitLabel() })));
- })(),
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')),
- h('p', { class: 'small muted', style: 'margin:0' },
- exitable.length ? t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) }) : t('arkExitNoneExitable')),
- h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !exitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))),
+ } }, t('arkCoinsWithdrawBtn', { amount: fmtNative(coopSat) })),
+ h('button', { class: 'btn-ghost btn-block ark-exit-ask', disabled: !!ui.arkBusy || !selExitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } },
+ t('arkCoinsExitBtnAmt', { amount: fmtNative(selExitSat) })))),
+ renewingCard,
+ embedded ? custodyCard() : null,
embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
}
@@ -2673,18 +2691,19 @@ export function arkFeature(ctx) {
{ id: 'self', label: t('arkCustodySelf'), desc: t('arkCustodySelfDesc', { days: lifeDays }), cost: t('arkCustodyCostSelf', { base: baseCost }) },
{ id: 'exit', label: t('arkCustodyExit'), desc: t('arkCustodyExitDesc', { hops }), cost: t('arkCustodyCostExit', { pct: earlyPct, base: baseCost }) },
].filter((x) => honors || x.id !== 'custodial');
+ const chosen = tiers.find((x) => x.id === cur) || tiers[0];
+ // A drop-down, and only the chosen strategy's description under it: three
+ // paragraphs at once read as a wall, and two of them are about choices
+ // the reader has not made.
return h('div', { class: 'card col ark-custody', style: 'gap:10px' },
- h('h3', { style: 'margin:0' }, t('arkCustodyTitle')),
+ h('h4', { style: 'margin:0' }, t('arkCustodyTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })),
- h('div', { class: 'col', style: 'gap:8px' }, ...tiers.map((x) => h('label', {
- class: 'row gap6 ark-custody-opt' + (x.id === cur ? ' active' : ''), style: 'align-items:flex-start;cursor:pointer',
- 'data-tier': x.id,
- },
- h('input', { type: 'radio', name: 'arkCustody', value: x.id, checked: x.id === cur, disabled: !honors, style: 'flex:0 0 auto;margin:3px 0 0',
- onChange: () => applyCustody(x.id) }),
- h('div', { class: 'col', style: 'gap:2px;flex:1' },
- h('div', { class: 'row between gap6', style: 'align-items:baseline' }, h('span', { style: 'white-space:nowrap' }, x.label), h('span', { class: 'small' + (x.id === 'custodial' ? ' faint' : ''), style: 'text-align:right' }, x.cost)),
- h('div', { class: 'small muted' }, x.desc))))),
+ h('select', { id: 'ark-custody-select', disabled: !honors, onChange: (e) => applyCustody(e.target.value) },
+ ...tiers.map((x) => h('option', { value: x.id, selected: x.id === cur }, x.label))),
+ // the price under the choice, not in the option (a long option text
+ // overflows the select on a phone)
+ h('div', { class: 'small ark-custody-cost' }, chosen.cost),
+ h('p', { class: 'small muted ark-custody-desc', style: 'margin:0' }, chosen.desc),
honors ? null : h('p', { class: 'small faint', style: 'margin:0' }, t('arkCustodyNotHere')));
}
@@ -2905,9 +2924,9 @@ export function arkFeature(ctx) {
// Fee = size × rate needs none of it. Still memoized on the coin set +
// feerate, since the advisory asks on every wallet render.
let _exitFeeMemo = { key: null, val: 0 };
- function estimateExitFeeSat(mgr) {
+ function estimateExitFeeSat(mgr, only = null) {
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
- const coins = exitableCoins(mgr.state, mgr._tipH || 0);
+ const coins = only || exitableCoins(mgr.state, mgr._tipH || 0);
const key = feeRate + '|' + coins.map((v) => v.id).join(',');
if (_exitFeeMemo.key === key) return _exitFeeMemo.val;
let total = 0;
@@ -2922,16 +2941,27 @@ export function arkFeature(ctx) {
return total;
}
- async function doArkExit() {
+ // ids: exactly these coins (the Manage page's ticked ones); else every coin
+ async function doArkExit(ids = null) {
ui.arkBusy = 'exit'; ui.arkError = ''; render();
try {
- const mgr = await connectArk();
- const spendables = exitableCoins(mgr.state, mgr._tipH || 0);
+ // 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
// that can't fund its hops just stalls at step zero. Refuse to start
// one Savings can't carry, with the number on the table.
- const feeEst = estimateExitFeeSat(mgr);
+ const feeEst = estimateExitFeeSat(mgr, spendables);
if ((wallet.spendable || 0) < feeEst) {
throw new Error(t('arkExitFeeShort', { need: fmtSats(feeEst), have: fmtSats(wallet.spendable || 0) }));
}
@@ -3201,6 +3231,7 @@ export function arkFeature(ctx) {
const tooSmall = !!q && spendable < q.minSat;
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')),
+ o.fromCoins ? h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')) : null,
h('label', { class: 'field' },
h('span', { class: 'lab' }, t('recipient')),
h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none',
@@ -3270,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) {
@@ -3278,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;
@@ -3385,10 +3454,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; }
@@ -3423,13 +3514,19 @@ export function arkFeature(ctx) {
const deepCoins = !policy.maxDepth ? [] : spendables.filter((v) =>
!expiringCoins.includes(v) && depthOf(v) >= policy.maxDepth && mgr.refreshFee([v], tip) * 10 <= v.amountSat);
if (!expiringCoins.length && !deepCoins.length) { arkRenewWarn = null; return; }
+ // Worth it, or not at all: an automatic renewal never spends more than
+ // a tenth of what it renews. 301 sats to renew a 1,000-sat coin and
+ // four bits of change restored an exit nobody would ever use (the
+ // exit itself would cost more than the coins). Coins left alone stay
+ // spendable — the server keeps honoring them.
+ const worthIt = (set) => mgr.refreshFee(set, tip) * 10 <= set.reduce((n, v) => n + v.amountSat, 0);
if (!expiringCoins.length) {
// nothing is expiring: this round is for the deep coins alone, and
// they pay for it — the floor logic below still applies
arkRenewWarn = null;
let renewSet = [...deepCoins, ...spendables.filter((v) => !deepCoins.includes(v) && v.amountSat < 1000)];
const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
- if (netSat(renewSet) < 330) return;
+ if (netSat(renewSet) < 330 || !worthIt(renewSet)) return;
await mgr.refresh(renewSet.map((v) => v.id));
render();
return;
@@ -3464,6 +3561,7 @@ export function arkFeature(ctx) {
const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()];
if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it
+ if (!worthIt(renewSet)) return; // not worth a tenth of itself: left alone, still spendable
await mgr.refresh(renewSet.map((v) => v.id));
render();
} catch {} // transient — the next throttled attempt retries
@@ -3766,7 +3864,7 @@ export function arkFeature(ctx) {
h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
spendable > 0
? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: () => doArkOffboard() },
- ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() }))
+ ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtNative(spendable) }))
: h('div', { class: 'small faint' }, exitingSat > 0 ? t('arkExitCoopHeld') : t('arkExitNoBalance'))),
// unilateral
h('div', { class: 'card col', style: 'gap:8px' },
@@ -3804,7 +3902,7 @@ export function arkFeature(ctx) {
// the Spending face's settings page: the custody choice, then its coins
spendingSettings() {
if (wallet.watchOnly) return null; // a locked wallet can't renew or exit
- return h('div', { class: 'col', style: 'gap:16px' }, custodyCard(), arkCoinsPage({ embedded: true }));
+ return arkCoinsPage({ embedded: true }); // the custody strategy sits inside, after the coins
},
// the wallet's own pages keep the top nav: these are pages, not takeovers
screenView() {
@@ -3991,9 +4089,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 16b93d4..78ba336 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -782,12 +782,17 @@ const STR = {
arkCoopSelectedOne: 'Only the coin ticked above will move.',
arkCoopSelectedAmt: '{n} selected coins · {amount}',
arkCoopSelectedAmtOne: '1 selected coin · {amount}',
- arkCustodyTitle: 'Custody',
- arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos. Renewing a coin before it expires keeps that right for another {days} days and costs a small fee. Coinos keeps honoring expired coins, so renewing is a choice:',
+ 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.',
+ 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',
arkCustodyExitDesc: 'Every transfer adds a hop to a coin’s history, and a unilateral exit pays one on-chain transaction per hop. This renews a coin before it expires, and also as soon as its history is {hops} hops deep, so bringing any coin on-chain stays close to a fresh coin’s price. Only for coins worth it: never more than a tenth of a coin on its renewal.',
arkCustodyCostNone: 'no fees',
@@ -820,8 +825,10 @@ const STR = {
arkRenewStageRetry: 'The last check with the server didn’t go through; the wallet keeps trying.',
arkRenewingHistory: 'Renewing',
arkRenewingOpen: 'See the coins',
- arkCoinsExitTitle: 'Unilateral exit',
- arkCoinsCoopTitle: 'Co-operative exit',
+ arkCoinsExitTitle: 'Exit',
+ arkCoinsCoopTitle: 'Withdraw',
+ arkCoinsWithdrawBtn: 'Withdraw {amount}',
+ arkCoinsExitBtnAmt: 'Exit {amount}',
arkOffboardBadAddress: 'Enter a valid on-chain address for this network.',
arkCoinsExitBtn: 'Exit now',
arkExitConfirmTitle: 'Start a unilateral exit?',
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();
});
});