aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js181
1 files changed, 141 insertions, 40 deletions
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();