aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js332
1 files changed, 227 insertions, 105 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 4b15099..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';
@@ -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')));
}
@@ -2659,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;
@@ -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;
@@ -3333,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());
@@ -3347,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(() => {});
@@ -3385,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; }
@@ -3423,13 +3526,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 +3573,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 +3876,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 +3914,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 +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();