aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark
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 /src/ark
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]>
Diffstat (limited to 'src/ark')
-rw-r--r--src/ark/manager.js48
1 files changed, 38 insertions, 10 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);