aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-08 13:48:00 -0700
committerAdam Soltys <[email protected]>2026-10-08 13:48:00 -0700
commit175e513273bfb2a362f9a08e3da5d5460a1d91c0 (patch)
tree1220829278076ee110fc4e81db124590674c0a34
parent6206eaa76431d0bb09ffa88abb3f02b8976a9ed3 (diff)
downloadcoinosv3-175e513273bfb2a362f9a08e3da5d5460a1d91c0.tar.gz
coinosv3-175e513273bfb2a362f9a08e3da5d5460a1d91c0.zip
Coins in another device's renewal are busy here too: the renewal sliver names its inputs, and ArkManager._free / freeCoins exclude coins an in-flight remote renewal holds from balance (counted pending), send/LN/offboard/exit selection, auto-renew, the backstop and the coins page (greyed with the round). Freed the moment that renewal reads finished, failed or lapsed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/ark/manager.js48
-rw-r--r--src/features/ark.js64
-rw-r--r--tools/ark-renewal-sync-test.js21
3 files changed, 94 insertions, 39 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 7861b43..cc828bf 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -76,6 +76,16 @@ const SPENT_BYTES_AFTER = 30 * 86400_000;
const DONE_HEAVY_AFTER = 7 * 86400_000;
const ACTION_HEAVY = ['destBytes', 'changeBytes', 'destBytesList', 'changeBytesList', 'htlcBytesList', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos'];
const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
+// 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 pruneArkState(state, now = Date.now()) {
if (!state) return state;
const actions = state.actions || [];
@@ -379,10 +389,26 @@ export class ArkManager {
return true;
}
+ // Coins another device's renewal holds: spendable as far as this device's
+ // own records go, but already in that device's round — offering them here
+ // only gets a send or a renewal refused. Freed again the moment that
+ // renewal reads as finished or failed (or its two-day cap lapses).
+ _remoteBusy() {
+ const busy = new Set();
+ for (const r of remoteRenewalsInFlight(this.state)) for (const id of r.inputIds || []) busy.add(id);
+ return busy;
+ }
+ // free to spend, renew or exit from THIS device
+ _free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id); }
+
balance() {
const tip = this._tipH || 0;
+ const busy = this._remoteBusy();
+ const live = (v) => !this._expired(v, tip);
const sum = (st) => this.state.vtxos
- .filter((v) => v.state === st && !this._expired(v, tip)).reduce((n, v) => n + v.amountSat, 0);
+ .filter((v) => (st === 'spendable' ? this._free(v, busy)
+ : v.state === st || (st === 'pending' && v.state === 'spendable' && busy.has(v.id))) && live(v))
+ .reduce((n, v) => n + v.amountSat, 0);
const expiredSat = this.state.vtxos
.filter((v) => v.state === 'spendable' && this._expired(v, tip)).reduce((n, v) => n + v.amountSat, 0);
// Boards whose funding tx is broadcast but whose vtxo isn't registered yet:
@@ -712,7 +738,7 @@ export class ArkManager {
async offboardQuote(spk, vtxoIds) {
const inputs = vtxoIds
? vtxoIds.map((id) => this._vtxo(id)).filter(Boolean)
- : this.state.vtxos.filter((v) => v.state === 'spendable');
+ : this.state.vtxos.filter((v) => this._free(v));
const tip = await this.chain.tipHeight();
const satVkb = await getOffboardFeeRate(this.arkUrl);
const parts = offboardFeeParts({
@@ -733,9 +759,9 @@ export class ArkManager {
}
const inputs = vtxoIds
? vtxoIds.map((id) => this._vtxo(id))
- : this.state.vtxos.filter((v) => v.state === 'spendable');
+ : this.state.vtxos.filter((v) => this._free(v));
if (!inputs.length) throw new Error('no spendable ark balance');
- if (inputs.some((v) => !v || v.state !== 'spendable')) throw new Error('vtxo not spendable');
+ if (inputs.some((v) => !this._free(v))) throw new Error('vtxo not spendable');
if (this.info.maxOffboardInputs && inputs.length > this.info.maxOffboardInputs) {
throw new Error(`too many coins for one offboard (${inputs.length}), refresh/consolidate first`);
}
@@ -841,7 +867,7 @@ export class ArkManager {
// ---- send ----
_selectInput(amountSat) {
const candidates = this.state.vtxos
- .filter((v) => v.state === 'spendable' && v.amountSat >= amountSat)
+ .filter((v) => this._free(v) && v.amountSat >= amountSat)
.sort((a, b) => a.amountSat - b.amountSat);
if (!candidates.length) {
const total = this.balance().spendableSat;
@@ -857,8 +883,9 @@ export class ArkManager {
// by many small receives stays spendable — the package cosign spends all
// the inputs atomically and the recipient just receives several vtxos.
_selectInputs(amountSat, tip = this._tipH || 0) {
+ const busy = this._remoteBusy();
const spendable = this.state.vtxos
- .filter((v) => v.state === 'spendable' && !this._expired(v, tip));
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip));
const single = spendable
.filter((v) => v.amountSat >= amountSat)
.sort((a, b) => a.amountSat - b.amountSat)[0];
@@ -1215,8 +1242,9 @@ export class ArkManager {
// smallest single vtxo that covers amount + routing + its expiry-dependent
// fee — else gather largest-first: each input funds its own HTLC vtxo with
// the same payment hash, and the server sums them into one payment.
+ const busy = this._remoteBusy();
const spendable = this.state.vtxos
- .filter((v) => v.state === 'spendable' && !this._expired(v, tip));
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip));
let inputs = null, feeSat = 0;
for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) {
const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing;
@@ -2008,8 +2036,8 @@ export class ArkManager {
async refresh(vtxoIds, { manual = false } = {}) {
const inputs = (vtxoIds
? vtxoIds.map((id) => this._vtxo(id))
- : this.state.vtxos.filter((v) => v.state === 'spendable'));
- if (!inputs.length || inputs.some((v) => !v || v.state !== 'spendable')) {
+ : this.state.vtxos.filter((v) => this._free(v)));
+ if (!inputs.length || inputs.some((v) => !this._free(v))) {
throw new Error('no spendable vtxos to refresh');
}
const tip = await this.chain.tipHeight();
@@ -2328,7 +2356,7 @@ export class ArkManager {
// an order that is going to die that quietly.
const cap = this.info.maxVtxoAmountSat || Infinity;
const coins = this.state.vtxos
- .filter((v) => v.state === 'spendable' && v.expiryHeight && !covered.has(v.id)
+ .filter((v) => this._free(v) && v.expiryHeight && !covered.has(v.id)
&& !this._expired(v, tip) && v.expiryHeight - marginBlocks > tip && v.amountSat <= cap)
.sort((a, b) => b.amountSat - a.amountSat)
.slice(0, maxCoins);
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();
diff --git a/tools/ark-renewal-sync-test.js b/tools/ark-renewal-sync-test.js
index af31cde..b29f832 100644
--- a/tools/ark-renewal-sync-test.js
+++ b/tools/ark-renewal-sync-test.js
@@ -4,6 +4,7 @@
// Run: bun tools/ark-renewal-sync-test.js
import assert from 'node:assert/strict';
import { slimArkForSync, mergeArkStates, remoteRenewalsInFlight } from '../src/features/ark.js';
+import { ArkManager } from '../src/ark/manager.js';
const now = Date.now();
const laptop = (step, extra = {}) => ({
@@ -16,7 +17,8 @@ let phone = { serverPubkey: 'srv', vtxos: [], movements: [], actions: [{ id: 'ln
const snap = slimArkForSync(laptop('submitted'));
assert.equal(snap.renewals.length, 1);
-assert.ok(!('fundingTxHex' in snap.renewals[0]) && !('outputVtxos' in snap.renewals[0]) && !('inputIds' in snap.renewals[0]), 'display fields only');
+assert.ok(!('fundingTxHex' in snap.renewals[0]) && !('outputVtxos' in snap.renewals[0]), 'no signing material');
+assert.deepEqual(snap.renewals[0].inputIds, ['a:0'], 'names the coins it holds');
phone = mergeArkStates(phone, snap);
assert.equal(phone.actions.length, 1, 'never adopted as an action of this device');
assert.equal(remoteRenewalsInFlight(phone).length, 1);
@@ -50,4 +52,21 @@ console.log('✓ Finished there, it is gone here');
assert.equal(mergeArkStates(phone, slimArkForSync(old)).remoteRenewals.filter((r) => r.id === old.actions[0].id).length, 0);
console.log('✓ The device that owns it never sees itself as remote; a renewal begun days ago is dropped');
}
+{
+ // the phone holds the laptop's renewal coin as spendable in its own records
+ const coin = (id, amountSat) => ({ id, amountSat, state: 'spendable', expiryHeight: 999_999, bytes: 'AA==' });
+ let st = { serverPubkey: 'srv', vtxos: [coin('a:0', 62337), coin('z:0', 1000)], movements: [], actions: [] };
+ st = mergeArkStates(st, slimArkForSync(laptop('submitted')));
+ const mgr = new ArkManager({ storage: { save() {} }, arkUrl: 'http://test.invalid' });
+ mgr.state = st; mgr._tipH = 1000;
+ assert.deepEqual(mgr.balance(), { spendableSat: 1000, pendingSat: 62337, boardingSat: 0, expiredSat: 0 });
+ assert.deepEqual(mgr._selectInputs(500).map((v) => v.id), ['z:0']);
+ assert.throws(() => mgr._selectInputs(2000));
+ await assert.rejects(mgr.refresh(['a:0']), /no spendable/);
+ console.log('✓ Its coins count as pending here and are never offered for a send or a renewal');
+ st = mergeArkStates(st, slimArkForSync(laptop('failed')));
+ mgr.state = st;
+ assert.equal(mgr.balance().spendableSat, 63337, 'a renewal that failed there frees them here');
+ console.log('✓ Failed there, its coins are free again here');
+}
console.log('\n✅ renewals sync across devices for display');