aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js64
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();