diff options
Diffstat (limited to 'src/ark/manager.js')
| -rw-r--r-- | src/ark/manager.js | 36 |
1 files changed, 33 insertions, 3 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index cc828bf..6741237 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -272,9 +272,21 @@ export class ArkManager { this.state.vtxos.push({ id: decoded.id, bytes: vtxoBytesToStr(bytes), keyIndex, amountSat: decoded.amountSat, expiryHeight: decoded.expiryHeight, state, + policy: decoded.policy.type, }); return true; } + // What a coin's script lets it be spent as. Only a plain 'pubkey' coin is + // ours to send: a Lightning payment's HTLC vtxo (serverHtlcSend) can only + // be taken by the server with the preimage or revoked back to a pubkey + // coin, never used as a round, offboard or arkoor input. Records from + // before this field learn it once, from their bytes. + _policyType(v) { + if (!v.policy) { + try { v.policy = this._decoded(v).policy.type; } catch { v.policy = 'pubkey'; } + } + return v.policy; + } _decoded(v) { // vtxo bytes are immutable per id — parse once. The depth advisory and // fee estimates decode every coin on every render without this. @@ -399,7 +411,7 @@ export class ArkManager { 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); } + _free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id) && this._policyType(v) === 'pubkey'; } balance() { const tip = this._tipH || 0; @@ -647,13 +659,32 @@ export class ArkManager { // action was pruned. Left alone it counted in the balance forever (thirty // zap-sized coins, 645 sats, on adam's laptop); the server settles it. const held = new Set(); + const htlcOutputs = (a) => a.htlcVtxoIds || (a.htlcVtxoId ? [a.htlcVtxoId] : []); for (const a of this.state.actions || []) { if (a.step === 'done' || a.step === 'failed') continue; for (const id of a.inputIds || []) held.add(id); for (const part of a.parts || []) if (part && part.inputId) held.add(part.inputId); + // A Lightning payment's HTLC vtxos are this action's too, from cosign + // until the server takes them or the revocation does. They stood + // outside this set, so a pass between two quick zaps saw the first + // zap's HTLC coin as an unheld 'pending' orphan, the server called it + // unspent, and it came back 'spendable' — the second zap picked it as + // its input and the server refused the policy (server-htlc-send-v1). + for (const id of htlcOutputs(a)) held.add(id); } for (const e of this.state.scheduled || []) for (const id of e.inputIds || []) held.add(id); - const candidates = this.state.vtxos.filter((v) => v.state === 'spendable' || (v.state === 'pending' && !held.has(v.id) && v.bytes)); + let changed = false; + // Heal coins that pass already freed: an HTLC vtxo can't be spendable + // here whatever the server's unspent/spent answer says. + for (const v of this.state.vtxos) { + if (v.state !== 'spendable' || this._policyType(v) === 'pubkey') continue; + const owner = (this.state.actions || []).find((a) => htlcOutputs(a).includes(v.id)); + if (!owner) continue; // not ours to settle; _free() keeps it out of balance and selection + v.state = owner.step === 'done' || owner.step === 'failed' ? 'spent' : 'pending'; + changed = true; + } + const candidates = this.state.vtxos.filter((v) => v.state === 'spendable' + || (v.state === 'pending' && !held.has(v.id) && v.bytes && this._policyType(v) === 'pubkey')); const was = candidates.map((v) => v.state); // what each was when this pass began // Each status query signs a challenge (schnorr) and may decode the vtxo — // real secp256k1 work. Building them all in one synchronous map was a @@ -667,7 +698,6 @@ export class ArkManager { if (performance.now() - t0 > 6) { await new Promise((r) => setTimeout(r)); t0 = performance.now(); } } const states = await Promise.all(pending); // unreachable/erroring server changes nothing - let changed = false; candidates.forEach((v, i) => { // A coin a payment took while the server was being asked is that // payment's now: the answer predates it. A double-tapped zap lost its |
