diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 181 |
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(); |
