diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 64 |
1 files changed, 36 insertions, 28 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 9b7cd65..90b2a01 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -6,7 +6,16 @@ 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 } from '../ark/manager.js'; +import { ArkManager, remoteRenewalsInFlight } from '../ark/manager.js'; +export { remoteRenewalsInFlight }; + +// Coins this device may offer: spendable, and not held by a renewal another +// device has in flight (see ArkManager._remoteBusy). +function freeCoins(st) { + if (!st) return []; + const busy = new Set(remoteRenewalsInFlight(st).flatMap((r) => r.inputIds || [])); + return (st.vtxos || []).filter((v) => v.state === 'spendable' && !busy.has(v.id)); +} 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'; @@ -159,6 +168,8 @@ const renewalStartedAt = (id) => Number(String(id).split('-').pop()) || 0; function renewalSliver(a) { return { id: a.id, step: a.step, superseded: !!a.superseded, manual: !!a.manual, + // which coins it holds, so other devices don't offer them meanwhile + inputIds: [...(a.inputIds || [])], outAmountSat: a.outAmountSat, feeSat: a.feeSat || 0, unlockHash: a.unlockHash || null, fundingTxid: a.fundingTxid || null, }; @@ -184,15 +195,6 @@ export function mergeRemoteRenewals(a, b, now = Date.now()) { return [...byId.values()].sort((x, y) => renewalStartedAt(x.id) - renewalStartedAt(y.id)); } -// Renewals another device has in flight, as this device should show them: -// not finished there, and not already finished as far as this device's -// history knows (the "Renewed" row synced ahead of the final telling). -export function remoteRenewalsInFlight(state) { - const settled = new Set((state.movements || []) - .filter((m) => m.type === 'refresh' && m.status === 'complete' && m.unlockHash).map((m) => m.unlockHash)); - return (state.remoteRenewals || []).filter((r) => - !['done', 'failed'].includes(r.step) && !(r.unlockHash && settled.has(r.unlockHash))); -} export function slimArkForSync(s) { if (!s) return null; @@ -777,7 +779,11 @@ export function arkFeature(ctx) { function arkBalance() { const s = arkStateNow(); if (!s) return null; - const sum = (st) => (s.vtxos || []).filter((v) => v.state === st && !v.expiryRejected).reduce((n, v) => n + v.amountSat, 0); + // coins in another device's renewal count as pending here, as they do there + const busy = new Set(remoteRenewalsInFlight(s).flatMap((r) => r.inputIds || [])); + const sum = (st) => (s.vtxos || []).filter((v) => !v.expiryRejected && (st === 'spendable' + ? v.state === 'spendable' && !busy.has(v.id) + : v.state === st || (st === 'pending' && v.state === 'spendable' && busy.has(v.id)))).reduce((n, v) => n + v.amountSat, 0); const boardingSat = (s.actions || []) .filter((a) => a.type === 'board' && a.fundingTxid && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + (a.amountSat - a.feeSat), 0); @@ -787,7 +793,9 @@ export function arkFeature(ctx) { function renewingSat() { const s = arkStateNow(); const ids = new Set((s?.actions || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step)).flatMap((a) => a.inputIds || [])); - return (s?.vtxos || []).filter((v) => v.state === 'pending' && ids.has(v.id)).reduce((n, v) => n + v.amountSat, 0); + const remote = new Set(s ? remoteRenewalsInFlight(s).flatMap((r) => r.inputIds || []) : []); + return (s?.vtxos || []).filter((v) => (v.state === 'pending' && ids.has(v.id)) || (v.state === 'spendable' && remote.has(v.id))) + .reduce((n, v) => n + v.amountSat, 0); } // An ark address in the send form signals a send is coming: verify our @@ -1095,7 +1103,7 @@ export function arkFeature(ctx) { const routing = await mgr.lnRouteFee(invoice, p.amountSat); if (!ui.arkLnPay || ui.arkLnPay !== p || p.invoice !== invoice) return; p.routingFeeSat = routing; - const spendables = mgr.vtxos().filter((v) => v.state === 'spendable'); + const spendables = freeCoins(mgr.state); let fee = null; for (const v of [...spendables].sort((a, b) => a.amountSat - b.amountSat)) { const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip) + routing; @@ -1202,7 +1210,7 @@ export function arkFeature(ctx) { // table rather than promising ones the fees will eat. function lnMaxSat() { const s = arkStateNow(); - const coins = (s ? (s.vtxos || []) : []).filter((v) => v.state === 'spendable') + const coins = freeCoins(s) .sort((a2, b2) => b2.amountSat - a2.amountSat).slice(0, 24); const sum = coins.reduce((n, v) => n + v.amountSat, 0); if (!sum) return 0; @@ -2107,7 +2115,7 @@ 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 = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); + const spendables = () => freeCoins(mgr.state); const total = spendables().reduce((n, v) => n + v.amountSat, 0); let ids; if (amountSat < total) { @@ -2180,8 +2188,7 @@ export function arkFeature(ctx) { if (!st.on || !st.dest || wallet.watchOnly || awBusy) return; if (Date.now() - (st.failedAt || 0) < 15 * 60_000) return; // backing off if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; - const spendable = (mgr.state.vtxos || []) - .filter((v) => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0); + const spendable = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0); const threshold = Number(st.threshold) || 0; const keep = Math.max(0, Number(st.keep) || 0); if (!threshold || spendable < threshold) return; @@ -2218,7 +2225,7 @@ export function arkFeature(ctx) { let _depthKey = null, _depthComputing = false; function computeDepthAdvisory() { if (!ark || !ark.state) return; - const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable'); + const spend = freeCoins(ark.state); const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const key = feeRate + '|' + spend.map((v) => v.id).join(','); if (key === _depthKey || _depthComputing) return; // already priced this exact set @@ -2264,14 +2271,16 @@ export function arkFeature(ctx) { // The manager is dialled meanwhile; what needs it live waits for it. const mgr = ark; const st = (mgr && mgr.state) || arkStateNow(); - const spend = ((st && st.vtxos) || []).filter((v) => v.state === 'spendable'); + // another device's renewal holds its coins just the same: greyed here too + const elsewhere = st ? remoteRenewalsInFlight(st) : []; + const spend = freeCoins(st); // Coins in a renewal round sit 'pending' until the fresh coin is signed // (a confirmation of the round's transaction, then a sync). They stay on // the page, greyed and unselectable, with the round's progress beside // them — the page used to shrink to one line the moment Renew was tapped. const renewing = ((st && st.actions) || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step)); - const inRound = new Set(renewing.flatMap((a) => a.inputIds || [])); - const pendingCoins = ((st && st.vtxos) || []).filter((v) => v.state === 'pending' && inRound.has(v.id)); + 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 back = embedded ? () => { ui.arkCoinsSel = null; ui.arkRenewConfirm = null; ctx.goHome(); } @@ -2390,7 +2399,6 @@ export function arkFeature(ctx) { const stageOf = (a) => a.step === 'issued' ? t('arkRenewStageFinishing') : a.step === 'submitted' ? t('arkRenewStageRound') : t('arkRenewStageQueued'); // another device's renewals, shown but never driven from here (it holds // their signing material); they lock the balance just the same - const elsewhere = st ? remoteRenewalsInFlight(st) : []; const renewingRows = [...renewing, ...elsewhere.map((r) => ({ ...r, elsewhere: true }))]; const renewingCard = renewingRows.length ? h('div', { class: 'card col', style: 'gap:8px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, @@ -2761,7 +2769,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 = mgr.vtxos().filter((x) => x.state === 'spendable'); + const coins = freeCoins(mgr.state); const key = feeRate + '|' + coins.map((v) => v.id).join(','); if (_exitFeeMemo.key === key) return _exitFeeMemo.val; let total = 0; @@ -2780,7 +2788,7 @@ export function arkFeature(ctx) { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { const mgr = await connectArk(); - const spendables = mgr.vtxos().filter((v) => v.state === 'spendable'); + const spendables = freeCoins(mgr.state); if (!spendables.length) throw new Error(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 @@ -3127,7 +3135,7 @@ export function arkFeature(ctx) { const address = destAddress || wallet.freshReceive().address; const spk = addrScript(address); const mgr = await connectArk(); - const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); + 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) { @@ -3215,7 +3223,7 @@ export function arkFeature(ctx) { // 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 // did nothing at all: no renewal attempt, and no warning either. - const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable'); + const spendables = freeCoins(mgr.state); if (!spendables.length) { arkRenewWarn = null; return; } // Register the server-side backstop for anything not covered yet. Ahead // of the in-flight guard below on purpose: a standing order takes no coin @@ -3555,7 +3563,7 @@ export function arkFeature(ctx) { const b = arkBalance() || { spendableSat: 0, pendingSat: 0 }; const spendable = b.spendableSat; const total = b.spendableSat + b.pendingSat; - const nSpend = ((arkStateNow() && arkStateNow().vtxos) || []).filter((v) => v.state === 'spendable').length; + const nSpend = freeCoins(arkStateNow()).length; const exitingSat = ((arkStateNow() && arkStateNow().actions) || []) .filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + a.amountSat, 0); @@ -4090,7 +4098,7 @@ export function arkFeature(ctx) { // normal case now — pass without comment, and one line under the // balance is enough: the settings page carries the full story. if (ark && ark.state && ark._tipH && !wallet.watchOnly) { - const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable' && v.expiryHeight && !v.expiryRejected); + const spend = freeCoins(ark.state).filter((v) => v.expiryHeight && !v.expiryRejected); if (spend.length) { const tip = ark._tipH; const RENEW = renewWindow(); |
