diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
| commit | 648a5503089d33232855925a24ff60f78cab6df4 (patch) | |
| tree | 1280073d797653d3ed86f0bb7b6c26b5d2271804 /src/features/ark.js | |
| parent | 8257a98e8d1891d0795f6b8b0623678245cc8e33 (diff) | |
| download | coinosv3-648a5503089d33232855925a24ff60f78cab6df4.tar.gz coinosv3-648a5503089d33232855925a24ff60f78cab6df4.zip | |
Custody is a choice, and coinos holding the coins is the default: nothing renews on its own and no renewal fee is paid unless the user opts in
Why: renewal fees were "money passively draining" to people who thought they had a custodial wallet (Stacker News, 2026-10-22). Measured over Sep–Oct: 84% of what users paid came from the client's own consolidation round at 20 coins (0.2% + 300 on every fresh coin), the rest from 300-sat rounds that each cohort of Lightning-received coins (30-day pool lifetime) triggered on its own schedule. The ASP itself honors a coin past its timelock — rounds and offboards never checked expiry, and captaind's allow_expired_arkoor (now on at ark.coinos.io) lets arkoor spends through too — so expiry only ever meant "the exit is gone", which most people coming from v2 never had.
Settings → Spending gets a Custody card with three tiers:
- coinos holds my coins (default): no renewals, no fees; a coin past its timelock shows as "held by coinos", stays spendable and receivable, and is left out of a unilateral exit (there is nothing on-chain for it to take).
- Keep my own exit: renew in the last hours (free bracket + base), the server backstop registered 36 blocks out and snapped to a 48-block grid so every wallet's orders share one round.
- Renew a week early: 1008 blocks out on a 144-block grid (0.1% + base).
renewPolicy() in ark/manager.js is the one table; the app, the service worker (the mirror carries the tier) and the Manage page all read it. Only shown on servers that honor expired coins (presets carry honorsExpired); elsewhere renewal stays automatic.
Manager: honorsExpired makes a past-timelock coin ordinary money (balance, selection, receive validation via acceptExpired) while a server refusal (expiryRejected) still excludes it; such coins spend LAST so an older build on the receiving side almost never meets one. A once-per-wallet rescan re-reads the mailbox and adopts coins older builds refused as expired, un-forgets expired dust, and clears stale refusals. The refresh gate that retired a renewal of expired inputs is gone (the server accepts them; the Manage page's Renew of exactly those coins was failing silently). Automatic consolidation is gone; the send cap goes 24 → 48 coins; small change (<1,000 sats) folds into a renewal that happens anyway.
Server (cs:~/ark/captaind-main.toml, restarted): allow_expired_arkoor = true; pool vtxo_lifetime 4320 → 8640 so Lightning-received coins live as long as the rest.
Tests: tools/ark/custody-test.js (policies, honoring, ordering, grid snap, validator), tools/ark/custody-ui-test.js (the card, held coins, exit count, switching), nwc-sw-test updated for the tier the worker reads.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/features/ark.js')
| -rw-r--r-- | src/features/ark.js | 218 |
1 files changed, 147 insertions, 71 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 3ef6cea..7a77d2f 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -8,7 +8,7 @@ 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, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js'; export { remoteRenewalsInFlight }; // Coins this device may offer: spendable, and not held by a renewal another @@ -18,6 +18,12 @@ 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)); +} 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'; @@ -541,7 +547,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'); @@ -623,6 +629,7 @@ export function arkFeature(ctx) { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + honorsExpired: !!cfg.honorsExpired, // 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 @@ -931,8 +938,9 @@ 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, @@ -987,7 +995,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++) { @@ -2314,6 +2322,10 @@ export function arkFeature(ctx) { 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; + // 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 @@ -2335,7 +2347,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 @@ -2463,13 +2475,13 @@ export function arkFeature(ctx) { h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')), h('div', { class: 'col', style: 'gap:4px' }, - row(t('arkExitConfirmCoins'), String(spend.length)), - row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' sats'), + 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('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')), short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), 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 || !exitable.length, onClick: () => { ui.arkExitConfirm = null; doArkExit(); ui.arkCoinsSel = null; @@ -2522,7 +2534,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; @@ -2551,9 +2564,11 @@ export function arkFeature(ctx) { 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 })), + 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), @@ -2577,11 +2592,50 @@ export function arkFeature(ctx) { 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'))), + 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'))), 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 with a week's margin (early). 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 info = (ark && ark.info) || null; + const honors = !!(cfg && cfg.honorsExpired); + const cur = honors ? getCustody() : 'self'; + 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 = base ? fmtSats(base) + ' sats' : t('arkDepthFree'); + const earlyPct = pct(ppmAt(1008)); + 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: 'early', label: t('arkCustodyEarly'), desc: t('arkCustodyEarlyDesc'), cost: t('arkCustodyCostEarly', { pct: earlyPct, base: baseCost }) }, + ].filter((x) => honors || x.id !== 'custodial'); + return h('div', { class: 'card col ark-custody', style: 'gap:10px' }, + h('h3', { 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))))), + 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) @@ -2801,7 +2855,7 @@ export function arkFeature(ctx) { let _exitFeeMemo = { key: null, val: 0 }; function estimateExitFeeSat(mgr) { const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); - const coins = freeCoins(mgr.state); + const coins = 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; @@ -2820,8 +2874,8 @@ export function arkFeature(ctx) { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { const mgr = await connectArk(); - const spendables = freeCoins(mgr.state); - if (!spendables.length) throw new Error(t('arkNotConnected')); + const spendables = exitableCoins(mgr.state, mgr._tipH || 0); + 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. @@ -2983,7 +3037,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; @@ -3199,25 +3253,48 @@ 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. + // early — renew a week ahead at 0.1% + base, for whoever wants a margin + // against coinos being unreachable right at the end. + // 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'; + function getCustody() { + const v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null; + return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + } + 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) || tier === getCustody()) return; + setCustody(tier); + 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 @@ -3251,9 +3328,13 @@ export function arkFeature(ctx) { async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; + const policy = custodyPolicy(); + // "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; } @@ -3265,25 +3346,18 @@ 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); + if (!expiringCoins.length) { arkRenewWarn = null; 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)); @@ -3294,26 +3368,24 @@ 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, ...spendables.filter((v) => !expiringCoins.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 + await mgr.refresh(renewSet.map((v) => v.id)); render(); } catch {} // transient — the next throttled attempt retries } @@ -3595,7 +3667,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); @@ -3650,10 +3722,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 h('div', { class: 'col', style: 'gap:16px' }, custodyCard(), arkCoinsPage({ embedded: true })); }, // the wallet's own pages keep the top nav: these are pages, not takeovers screenView() { @@ -4113,7 +4185,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 |
