aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js660
1 files changed, 488 insertions, 172 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index cbfd1b9..5ed9a9d 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1,12 +1,14 @@
+import { momentTags } from '../media-timeline.js';
// Ark feature — off-chain payments via an ASP (Second's bark/captaind
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
+import { zapAddrOf } from '../live.js';
import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, backstopMargin } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, sameDelivery, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -16,6 +18,17 @@ function freeCoins(st) {
const busy = new Set(remoteRenewalsInFlight(st).flatMap((r) => r.inputIds || []));
return (st.vtxos || []).filter((v) => v.state === 'spendable' && !busy.has(v.id));
}
+// The coins a unilateral exit can still take on-chain. Past its timelock a
+// coin's tree is the server's to sweep (and it has): only the server can
+// honor it now, and it does — but no exit transaction will ever confirm.
+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';
@@ -30,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()); }
@@ -452,8 +465,41 @@ export function arkFeature(ctx) {
// One manager per open wallet; null when Ark is off in Settings, watch-only,
// or not yet dialed (lazy: fresh wallets connect on first use).
let ark = null;
+ // The same manager the moment its own init is done (server info, tip,
+ // persisted coins) — before the Nostr snapshot restore that `ark` also
+ // waits for, which can take ten seconds on a slow relay/Blossom day. For
+ // READING only: the pages price and label coins from it; anything that
+ // 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.
+ const ARK_INFO_KEY = 'btc-wallet-arkinfo:';
+ function cachedArkInfo(url) {
+ 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) 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) {
+ if (mgr && mgr._tipH) return mgr._tipH;
+ const c = cachedArkInfo((getArkConfig() || {}).ark);
+ return c && c.tip ? c.tip + Math.floor((Date.now() - (c.at || Date.now())) / 600_000) : 0;
+ }
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() {
@@ -472,7 +518,9 @@ export function arkFeature(ctx) {
clearTimeout(lnTagT);
lnTagSeen = -1;
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
@@ -504,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(() => {}); }
}
@@ -539,7 +589,7 @@ export function arkFeature(ctx) {
adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, p.ark),
save: (s) => { wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(p.ark)), p.ark); },
},
- arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(),
+ arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), honorsExpired: !!p.honorsExpired,
});
await mgr.init();
const done = () => (mgr.state.movements || []).filter((m) => m.type === 'ln-receive' && m.status === 'complete');
@@ -553,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.
@@ -621,6 +671,10 @@ export function arkFeature(ctx) {
arkUrl: cfg.ark,
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
@@ -634,9 +688,27 @@ 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);
- arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => {
+ 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
+ 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.
+ inited.then(() => mgr._tipMemo()).then(() => {
+ if (gen !== arkInitGen || !mgr._tipH) return;
+ rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
+ render();
+ }).catch(() => {});
+ 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)
@@ -677,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) => {
@@ -929,14 +1003,15 @@ export function arkFeature(ctx) {
if (offer === undefined) offer = (prev && prev.offer) || null;
if (prefs === undefined) prefs = (prev && prev.prefs) || undefined;
const rec = buildBg({
- ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey },
+ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired },
mgrState: ark.state,
+ custody: getCustody(),
keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey),
connections,
offer,
autowithdraw: await awMirror(prev),
});
- if (prev) rec.spends = prev.spends; // the worker's log survives rewrites
+ if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites
if (prefs) rec.prefs = prefs; // notification choices the worker honours
await saveBg(wallet._cacheKey(), rec);
await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {});
@@ -955,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]));
@@ -964,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:
@@ -985,7 +1064,7 @@ export function arkFeature(ctx) {
const pm = await new ArkManager({
account: shim,
storage: { load: () => state, save: (s2) => { state = s2; } },
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
+ arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired,
}).init();
const dest = mgr.address();
for (let guard = 0; guard < 30; guard++) {
@@ -1429,6 +1508,25 @@ export function arkFeature(ctx) {
);
}
+ // a movement as a line of the history export (Settings → Wallet): what
+ // was paid, the fee beside it, and the change to the balance — a failed
+ // payment moved nothing; a renewal moved nothing but its fee
+ function arkCsvRow(m) {
+ if (m.status === 'failed') return null;
+ const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type);
+ const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0;
+ const amount = m.type === 'refresh' ? 0 : m.amountSat || 0;
+ const viaLn = m.type.startsWith('ln-') || m.viaLn;
+ return {
+ ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type,
+ rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark',
+ amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount,
+ status: m.status === 'complete' ? 'complete' : 'pending',
+ ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '',
+ note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '',
+ };
+ }
+
const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now();
// A Lightning payment (a zap, mostly) still settling: the money has left
// the balance, so history shows it now as the Sent row it will become.
@@ -1572,6 +1670,7 @@ export function arkFeature(ctx) {
})(),
m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null,
m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null,
+ m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null,
// the coins this payment consumed — a send can gather up to 24 inputs
coinListRows(t('arkVtxoInputs'), m.inputIds),
// An offboard's fee is two different pockets — the miners' cut for the
@@ -1711,8 +1810,8 @@ export function arkFeature(ctx) {
// 2. lightning: profile lud16/lud06, via the zap flow
// 3. an on-chain address from the BIP-353 record
const NAMES_REGISTRAR = 'https://names.coinos.io';
- function startNpubPay(pk, npub, eventId = null, autoSat = 0) {
- const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat };
+ function startNpubPay(pk, npub, eventId = null, autoSat = 0, moment = null) {
+ const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat };
// A one-tap post zap has no card: it resolves and pays off-screen, so a
// second tap — the same post again, or another — never evicts the one
// already in the air. Everything else takes the send screen's zap slot.
@@ -1794,7 +1893,7 @@ export function arkFeature(ctx) {
z.lnOk = true;
await connectArk().catch(() => {});
if (!live()) return;
- if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
+ if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment); return; }
}
// 3. on-chain fallback (a one-tap zap can't take the send form)
if (onchain && !(z.autoSat && z.eventId)) {
@@ -1914,7 +2013,7 @@ export function arkFeature(ctx) {
await wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()],
}).catch(() => {});
}
@@ -1957,7 +2056,7 @@ export function arkFeature(ctx) {
wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
}).catch(() => {});
const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
@@ -2045,7 +2144,7 @@ export function arkFeature(ctx) {
// Only someone with a Lightning address gets the Lightning door (the
// lookup normally hands such a person straight to the zaps feature).
noAddr && z.lnOk && ctx.hook('canLnZap')
- ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
+ ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment); } }, '⚡ ' + t('lnZapFallback'))
: null,
payable && spendable < 330
? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance'))
@@ -2126,23 +2225,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;
}
@@ -2280,7 +2364,7 @@ export function arkFeature(ctx) {
// page, or a tap before the manager has connected (the first seconds
// after boot) found no coins and closed itself — Manage needed two taps.
// The manager is dialled meanwhile; what needs it live waits for it.
- const mgr = ark;
+ const mgr = arkView();
const st = (mgr && mgr.state) || arkStateNow();
// another device's renewal holds its coins just the same: greyed here too
const elsewhere = st ? remoteRenewalsInFlight(st) : [];
@@ -2292,7 +2376,11 @@ export function arkFeature(ctx) {
const renewing = ((st && st.actions) || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step));
const inRound = new Set([...renewing, ...elsewhere].flatMap((a) => a.inputIds || []));
const pendingCoins = ((st && st.vtxos) || []).filter((v) => (v.state === 'pending' || v.state === 'spendable') && inRound.has(v.id));
- const tip = (mgr && mgr._tipH) || 0;
+ const tip = arkTipNow(mgr);
+ // the coinos servers keep honoring a coin past its timelock: it is held
+ // by coinos, not expired — still money, just not exitable on its own
+ const honors = !!(mgr && mgr.honorsExpired);
+ const exitable = exitableCoins(st, tip);
const back = embedded
? () => { ui.arkCoinsSel = null; ui.arkRenewConfirm = null; ctx.goHome(); }
: () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkRenewConfirm = null; }); // the page is a history entry now
@@ -2314,7 +2402,7 @@ export function arkFeature(ctx) {
const expiresOf = (v) => {
if (!v.expiryHeight || !tip) return '—';
const blocks = v.expiryHeight - tip;
- if (blocks <= 0) return t('arkCoinsExpired');
+ if (blocks <= 0) return honors ? t('arkCoinsHeld') : t('arkCoinsExpired');
return blocks >= 144 ? Math.round(blocks / 144) + 'd' : Math.max(1, Math.round(blocks / 6)) + 'h';
};
// what pulling THIS coin on-chain alone would cost: every hop's fee
@@ -2332,10 +2420,11 @@ export function arkFeature(ctx) {
// bracket the share is zero, where the wallet renews on its own anyway.
// A flat base fee (the round's on-chain cost, which coinos stopped
// absorbing in October 2026) rides on top of every bracket.
- const table = (((mgr && mgr.info) || {}).refreshFees || {}).ppmExpiryTable || [];
- const baseSat = (((mgr && mgr.info) || {}).refreshFees || {}).baseFeeSat || 0;
+ const infoNow = (mgr && mgr.info) || (cachedArkInfo((getArkConfig() || {}).ark) || {}).info || {};
+ const table = (infoNow.refreshFees || {}).ppmExpiryTable || [];
+ const baseSat = (infoNow.refreshFees || {}).baseFeeSat || 0;
const baseCost = fmtSats(baseSat) + ' sats';
- const roundMins = Math.max(1, Math.round((((mgr && mgr.info) || {}).roundIntervalSecs || 120) / 60));
+ const roundMins = Math.max(1, Math.round((infoNow.roundIntervalSecs || 120) / 60));
const roundEvery = roundMins === 1 ? t('arkCoinsRoundEveryOne') : t('arkCoinsRoundEvery', { n: roundMins });
const ppmFor = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
// A coin's SHARE of the renewal fee, fractional: the server totals the
@@ -2373,6 +2462,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;
@@ -2383,6 +2478,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
@@ -2395,6 +2504,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'),
@@ -2402,7 +2512,7 @@ export function arkFeature(ctx) {
row(t('arkRenewConfirmLeft'), fmtSats(leftSat) + ' sats', leftSat ? '' : 'err')),
leftSat ? null : h('div', { class: 'notice err small' }, t('arkRenewConfirmAll')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmWait')),
- h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !mgr, onClick: renew },
+ h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !ark, onClick: renew },
selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkRenewConfirm = null; render(); } }, t('cancel')));
}
@@ -2435,22 +2545,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(spend.length)),
- row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' 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 || !spend.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
@@ -2501,7 +2612,8 @@ export function arkFeature(ctx) {
h('tbody', {}, ...spend.map((v) => {
const f = feeNowOf(v);
const xf = exitFeeOf(v);
- const due = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0) || !!v.expiryRejected;
+ const past = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0);
+ const due = (past && !honors) || !!v.expiryRejected;
// expired and under the floor: nothing can renew it, so the
// last column offers to give it up
const lost = due && v.amountSat < 330;
@@ -2524,43 +2636,78 @@ 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'))))))))),
+ 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;
+ ui.arkOffboarded = null; ui.arkError = '';
+ ctx.showSend({ fresh: true });
+ ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds };
+ render();
+ } }, 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,
- 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' }, 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 || !mgr, 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('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !spendableSat || wallet.watchOnly, onClick: () => {
- const address = wallet.freshReceive().address;
- ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null;
- ui.arkOffboarded = null; ui.arkError = '';
- ctx.showSend({ fresh: true });
- ui.arkOffboardSend = { address, amount: String(spendableSat), fromCoins: true };
- render();
- } }, t('arkOffboardBtn', { n: fmtAmount(spendableSat) + ' ' + unitLabel() }))),
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')),
- h('p', { class: 'small muted', style: 'margin:0' },
- t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })),
- h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !spend.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))),
+ embedded ? custodyCard() : null,
embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
}
+ // Who holds the coins once their timelock runs out — the user's call, made
+ // here, with what each option costs in the server's own numbers. The
+ // default is coinos: most people come from v2, where coinos held
+ // everything, and the server honors a coin past its timelock anyway. The
+ // two renewing tiers are the opt-ins, for those who want the exit kept
+ // (self) or kept cheap by flattening deep histories early (exit). Only
+ // shown on a server that honors expired coins; elsewhere renewal is the
+ // only way to keep money.
+ function custodyCard() {
+ if (!arkAvailable() || wallet.watchOnly) return null;
+ const cfg = getArkConfig();
+ const view = arkView();
+ const info = (view && view.info) || (cachedArkInfo(cfg && cfg.ark) || {}).info || null;
+ const honors = !!(cfg && cfg.honorsExpired);
+ 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;
+ const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144));
+ const baseCost = !info ? '…' : base ? fmtSats(base) + ' sats' : t('arkDepthFree');
+ // an early renewal pays the bracket a fresh coin sits in (the top one)
+ const earlyPct = pct(ppmAt(((info && info.vtxoExpiryDelta) || 8640) - 1));
+ const hops = (renewPolicy('exit', getNetwork()) || {}).maxDepth || 4;
+ const tiers = [
+ { id: 'custodial', label: t('arkCustodyCustodial'), desc: t('arkCustodyCustodialDesc'), cost: t('arkCustodyCostNone') },
+ { 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('h4', { style: 'margin:0' }, t('arkCustodyTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })),
+ 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')));
+ }
+
// What each server charges and promises, from its own published info —
// laid side by side under the switcher so the choice is an informed one.
const serverInfos = new Map(); // url -> info | null (failed) | undefined (loading)
@@ -2778,9 +2925,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 = freeCoins(mgr.state);
+ 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;
@@ -2795,16 +2942,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 = freeCoins(mgr.state);
- if (!spendables.length) throw new Error(t('arkNotConnected'));
+ // 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) }));
}
@@ -2962,7 +3120,7 @@ export function arkFeature(ctx) {
load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } },
save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} },
},
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
+ arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired,
});
await mgr.init();
return mgr;
@@ -3054,7 +3212,7 @@ export function arkFeature(ctx) {
async function quoteOffboard(o, address) {
o.quoteFor = address; o.quote = null;
let quote = null;
- try { quote = await (await connectArk()).offboardQuote(addrScript(address)); }
+ try { quote = await (await connectArk()).offboardQuote(addrScript(address), o.vtxoIds); }
catch {} // no quote: the send itself still says what went wrong
if (o.quoteFor !== address) return; // the address changed meanwhile
o.quote = quote; o.quoteDone = true;
@@ -3062,7 +3220,9 @@ export function arkFeature(ctx) {
}
function arkOffboardSendView() {
const o = ui.arkOffboardSend;
- const spendable = arkBalance()?.spendableSat || 0;
+ // chosen coins: the amount is theirs and is not a field
+ const chosen = o.vtxoIds ? freeCoins(arkStateNow()).filter((v) => o.vtxoIds.includes(v.id)) : null;
+ const spendable = chosen ? chosen.reduce((n, v) => n + v.amountSat, 0) : (arkBalance()?.spendableSat || 0);
if (o.quoteFor !== o.address) {
o.quote = null; o.quoteDone = false; o.quoteFor = o.address;
let ok = false; try { addrScript(o.address); ok = true; } catch {}
@@ -3072,6 +3232,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',
@@ -3082,10 +3243,11 @@ export function arkFeature(ctx) {
fee: fmtSats(q.totalSat), chain: fmtSats(q.chainSat), service: fmtSats(q.serviceSat), min: fmtSats(q.minSat),
})) : !o.quoteDone ? h('div', { class: 'small faint' }, t('arkOffboardQuoting')) : null,
tooSmall ? h('div', { class: 'notice' }, t('arkOffboardBelowMin', { n: fmtSats(spendable), min: fmtSats(q.minSat) })) : null,
- h('div', { class: 'input-group' },
- h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
- onInput: (e) => { o.amount = e.target.value; } }),
- h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
+ chosen ? h('div', { class: 'small ark-coop-chosen' }, chosen.length === 1 ? t('arkCoopSelectedAmtOne', { amount: fmtSats(spendable) + ' sats' }) : t('arkCoopSelectedAmt', { n: chosen.length, amount: fmtSats(spendable) + ' sats' }))
+ : h('div', { class: 'input-group' },
+ h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
+ onInput: (e) => { o.amount = e.target.value; } }),
+ h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: () => {
@@ -3095,7 +3257,7 @@ export function arkFeature(ctx) {
ui.arkBusy === 'offboard'
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' }))
: h('button', { class: 'btn-primary grow', disabled: tooSmall, onClick: () => {
- const amount = String(o.amount ?? '').trim();
+ const amount = chosen ? String(spendable) : String(o.amount ?? '').trim();
const sats = amount ? Number(amount) : spendable;
if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; }
try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; }
@@ -3104,7 +3266,7 @@ export function arkFeature(ctx) {
ui.arkCoopFailed = false;
ui.arkError = t('arkOffboardAmtBelowMin', { min: fmtSats(q.minSat), fee: fmtSats(q.totalSat) }); render(); return;
}
- doArkOffboard(sats >= spendable ? 0 : sats, o.address);
+ doArkOffboard(sats >= spendable ? 0 : sats, o.address, chosen ? o.vtxoIds : undefined);
} }, t('arkSendBtn'))),
// This send IS the cooperative exit. The trustless fallback only
// appears when cooperation actually failed (not when the amount was
@@ -3140,29 +3302,69 @@ export function arkFeature(ctx) {
})());
}
- async function doArkOffboard(amountSat, destAddress = null) {
+ // 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) {
ui.arkBusy = 'offboard'; ui.arkError = ''; ui.arkCoopFailed = false; render();
try {
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);
- let ids; // undefined = all spendable vtxos
- if (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];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ let ids = vtxoIds; // undefined = all spendable vtxos
+ if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat);
const action = await mgr.startOffboard(spk, address, ids);
ui.arkOffboardAmt = '';
ui.arkMoveOpen = false;
@@ -3178,25 +3380,63 @@ export function arkFeature(ctx) {
ui.arkBusy = null; render();
}
- // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount
- // (fragmentation breaks coin selection) and every vtxo expires at
- // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than
- // exposing a refresh button, consolidate-and-renew automatically when a
- // vtxo is inside the renewal window or fragmentation builds up. The round
- // fee this spends is the cost of keeping the money spendable at all.
- // The ASP's refresh fee brackets reward renewing LATE — under 96 blocks of
- // remaining life a renewal is free, and this ASP keeps honoring (and freely
- // rescuing) a coin even past its expiry, so aiming inside the free window
- // risks nothing. ~16 hours of margin on mainnet.
- const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96);
- // How close to expiry the SERVER's own backstop renewal is aimed. The
- // window above only helps a wallet someone opens inside it; this one is a
- // participation registered far in advance that the ASP runs on its own,
- // whether or not anybody is looking. It sits well inside renewWindow so an
- // app that IS open still renews first (and the untouched backstop is
- // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for
- // the round to actually run in time. Both are in the free fee bracket.
- const backstopWindow = () => backstopMargin(getNetwork());
+ // VTXO upkeep, by the user's choice of custody (Settings → Spending):
+ // custodial (the default) — nothing renews. A coin past its timelock is
+ // coinos's IOU: the server sweeps the tree and keeps cosigning spends of
+ // the coin (captaind allow_expired_arkoor), so the balance works as a v2
+ // one did, and no renewal fee is ever paid. The holder trusts coinos.
+ // self — renew in the last hours before expiry, the free ppm bracket, one
+ // base fee per round (~300 sats): a unilateral exit is always possible.
+ // exit — the same, plus a coin is renewed as soon as its history is four
+ // hops deep: an exit pays one on-chain transaction per hop, so this
+ // keeps the exit price of every coin near a fresh coin's. Costs the
+ // top bracket + base each time, so only for coins worth it.
+ // The policy's window is when THIS app renews a coin (blocks of life left);
+ // margin/grid aim the SERVER's own backstop order — a participation the ASP
+ // runs on its own, registered far in advance, snapped to a grid so every
+ // wallet's orders share one round. renewPolicy() in ark/manager.js.
+ //
+ // Consolidation is gone from here: it used to trigger at 20 coins, bill the
+ // top ppm bracket on every fresh coin, and was 84% of all renewal fees paid
+ // in Sep–Oct 2026 — the "money draining" people saw. Multi-input sends take
+ // up to MAX_SEND_INPUTS coins; small change folds into a renewal that is
+ // 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)
+ 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());
+ // Switching: a renewing tier starts covering coins right away (the throttles
+ // are reset so the next sync registers backstops and renews what is due),
+ // and the worker's mirror learns the tier so closed-app renewals follow it.
+ // Orders already registered with the server keep running whatever the tier
+ // (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)) 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(() => {});
+ toast(t('arkCustodySaved'));
+ render();
+ }
let arkAutoRefreshAt = 0;
let arkBackstopAt = 0;
// Set when the balance is too small to renew itself (total minus round fee
@@ -3227,12 +3467,38 @@ 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; }
// NOT filtered by expiryRejected. A coin the server refused as expired
// can still be REFRESHED — that is the one thing an expired vtxo can
- // still do — and excluding it here meant a wallet holding nothing else
+ // always do — and excluding it here meant a wallet holding nothing else
// did nothing at all: no renewal attempt, and no warning either.
const spendables = freeCoins(mgr.state);
if (!spendables.length) { arkRenewWarn = null; return; }
@@ -3244,25 +3510,43 @@ export function arkFeature(ctx) {
// stamped, and this must not then run on every five-second sync.
if (Date.now() - arkBackstopAt > 30 * 60_000) {
arkBackstopAt = Date.now();
- await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {});
+ await mgr.ensureScheduledRefreshes(policy.margin, 8, policy.grid).catch(() => {});
}
// never start a round while any other action is still in flight
if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
arkAutoRefreshAt = Date.now();
try {
const tip = await mgr.chain.tipHeight();
- const RENEW_BLOCKS = renewWindow();
- const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS);
- const expiring = expiringCoins.length > 0;
- // Fragmentation alone is no longer worth paying for: multi-input sends
- // handle up to 24 coins, and consolidating FRESH coins bills the round
- // fee's top ppm bracket (a burst of refund coins after failed payments
- // once cost a user ~1% overnight). Consolidate only when the coin count
- // itself approaches the 24-input send ceiling.
- const fragmented = spendables.length >= 20;
+ const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < policy.window);
+ // The exit tier also renews a coin whose history has grown deep — each
+ // hop is one more on-chain transaction in a unilateral exit — but only
+ // a coin worth it: never more than a tenth of the coin on its renewal
+ // (300 sats to flatten a 21-sat coin's history would be absurd, and
+ // such a coin's exit is uneconomic whatever its depth).
+ const depthOf = (v) => { try { return mgr._decoded(v).genesis.length; } catch { return 0; } };
+ 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 || !worthIt(renewSet)) return;
+ await mgr.refresh(renewSet.map((v) => v.id));
+ render();
+ return;
+ }
const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0);
const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330;
- if (expiring && !renewable) {
+ if (!renewable) {
// Can't save these coins ourselves — tell the user while receiving
// any amount (or spending) can still beat the clock.
const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity));
@@ -3273,26 +3557,25 @@ export function arkFeature(ctx) {
return;
}
arkRenewWarn = null;
- if (!expiring && !fragmented) return;
// Renew ONLY the coins that need it: dragging far-from-expiry coins
// into the round would bill their (higher) fee bracket for nothing.
- // The rare fragmentation consolidation still sweeps everything.
- //
- // But the subset must clear the server's 330-sat output floor BY
- // ITSELF — the renewable check above prices the whole balance. Two
- // dust coins at expiry (133 sats between them) failed their renewal
- // round silently every 30 minutes forever. Pull in the smallest
- // healthy coins until the renewed output is mintable.
- let renewSet = expiringCoins;
- if (!fragmented) {
- const extras = spendables
- .filter((v) => !expiringCoins.includes(v))
- .sort((a, b) => a.amountSat - b.amountSat);
- 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
- }
- await mgr.refresh(fragmented ? undefined : renewSet.map((v) => v.id));
+ // Small change rides along, though: a coin under 1,000 sats costs at
+ // most 2 sats of ppm in this round, and folding it in now spares it a
+ // whole base fee's worth of round of its own when its turn comes.
+ let renewSet = [...expiringCoins, ...deepCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && !deepCoins.includes(v) && v.amountSat < 1000)];
+ // The set must clear the server's 330-sat output floor BY ITSELF — the
+ // renewable check above prices the whole balance. Two dust coins at
+ // expiry (133 sats between them) failed their renewal round silently
+ // every 30 minutes forever. Pull in the smallest healthy coins until the
+ // renewed output is mintable.
+ const extras = spendables
+ .filter((v) => !renewSet.includes(v))
+ .sort((a, b) => a.amountSat - b.amountSat);
+ 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
}
@@ -3574,7 +3857,7 @@ export function arkFeature(ctx) {
const b = arkBalance() || { spendableSat: 0, pendingSat: 0 };
const spendable = b.spendableSat;
const total = b.spendableSat + b.pendingSat;
- const nSpend = freeCoins(arkStateNow()).length;
+ const nSpend = exitableCoins(arkStateNow(), (ark && ark._tipH) || 0).length;
const exitingSat = ((arkStateNow() && arkStateNow().actions) || [])
.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step))
.reduce((n, a) => n + a.amountSat, 0);
@@ -3594,7 +3877,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' },
@@ -3629,10 +3912,10 @@ export function arkFeature(ctx) {
new Promise((r) => setTimeout(r, 800)),
]);
},
- // the Spending face's settings page leads with its coins
+ // 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 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() {
@@ -3819,9 +4102,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();
@@ -3909,9 +4204,9 @@ export function arkFeature(ctx) {
// ⚡ button) — through the same resolution ladder as a pasted npub. The
// event id rides into the receipt's e tag; callers fall back to the
// lnZapNpub hook when ark can't serve this build/wallet.
- zapNpub(pk, npub, eventId, autoSat) {
+ zapNpub(pk, npub, eventId, autoSat, moment) {
if (!arkAvailable() || !wallet.nostrFetch) return false;
- startNpubPay(pk, npub, eventId || null, autoSat || 0);
+ startNpubPay(pk, npub, eventId || null, autoSat || 0, moment);
return true;
},
sendFormExtras() {
@@ -3955,7 +4250,7 @@ export function arkFeature(ctx) {
const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash));
const lnPending = (s.actions || [])
.filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash))
- .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; });
+ .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; });
// Same-payment duplicates (the old settle race, or a snapshot adopted
// whole before the merge learned to dedupe) fold at display time too —
// the earliest telling wins, everywhere, however the state got here.
@@ -3995,7 +4290,22 @@ export function arkFeature(ctx) {
for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id);
const out = [];
const seenCoin = new Set();
+ // One payment delivered as several coins (a gift held in two, a
+ // dust-padded split) is one row: receives from the same mailbox
+ // message (`batch`; older rows: the same delivery instant) fold.
+ const folded = new Map();
for (const m of moves) {
+ if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) {
+ const last = out[out.length - 1];
+ if (!folded.has(last.id)) {
+ const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null };
+ out[out.length - 1] = f; folded.set(f.id, f);
+ }
+ const f = folded.get(last.id);
+ f.amountSat += m.amountSat;
+ f.vtxoIds.push(m.vtxoId);
+ continue;
+ }
if (m.type !== 'reconcile') { out.push(m); continue; }
if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue;
if (m.vtxoId) seenCoin.add(m.vtxoId);
@@ -4008,7 +4318,8 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
- return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })));
+ this._histFolded = folded; // a folded row's detail shows the whole payment
+ return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
if (ui.arkExitDetail) {
@@ -4027,7 +4338,8 @@ export function arkFeature(ctx) {
if (done) return arkMoveDetailView(done);
if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
}
- const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
+ if (s) this.historyEntries(); // (memoized) keeps the folded rows current
+ const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
return null;
@@ -4092,7 +4404,11 @@ export function arkFeature(ctx) {
// and it is one tap away on the Manage page. What it cannot do is be
// spent over Ark or exited unilaterally, so saying nothing is the one
// option that isn't honest.
- if (!wallet.watchOnly && ark && ark.balance) {
+ // On the coinos servers a coin past its timelock is still money (the
+ // server keeps cosigning its spends) — the balance counts it and
+ // there is nothing to warn about; the custody tier the user chose
+ // says what expiry means to them. Elsewhere, the old warning.
+ if (!wallet.watchOnly && ark && ark.balance && !ark.honorsExpired) {
let expiredSat = 0;
try { expiredSat = ark.balance().expiredSat || 0; } catch {}
// Expired dust (under the 330-sat floor) cannot be renewed or exited