diff options
| author | Adam Soltys <[email protected]> | 2026-10-08 13:48:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-08 13:48:00 -0700 |
| commit | 175e513273bfb2a362f9a08e3da5d5460a1d91c0 (patch) | |
| tree | 1220829278076ee110fc4e81db124590674c0a34 /src/ark | |
| parent | 6206eaa76431d0bb09ffa88abb3f02b8976a9ed3 (diff) | |
| download | coinosv3-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.js | 48 |
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); |
