aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 01:43:15 -0700
committerAdam Soltys <[email protected]>2026-10-09 01:43:15 -0700
commit817cc4a2dc0c7d343864da594aeb3c32b3459ffb (patch)
tree2da18d52b3a47f6ff56e49dac30a6e96febc32b6 /src
parentde8cb7a6f2272fa0b601a155dd244fe3a3a3f331 (diff)
downloadcoinosv3-817cc4a2dc0c7d343864da594aeb3c32b3459ffb.tar.gz
coinosv3-817cc4a2dc0c7d343864da594aeb3c32b3459ffb.zip
Ark: a Lightning payment's HTLC vtxo is never a coin to spend. The reconcile pass held an in-flight action's inputs but not its HTLC outputs, so between two quick zaps it took the first zap's HTLC coin for an unheld 'pending' orphan, the server called it unspent, and it came back 'spendable' — the second zap chose it as its input and the server refused the policy ("not spendable as a round, offboard or arkoor input (policy: server-htlc-send-v1)"). HTLC outputs are now held with their action, _free() refuses any non-pubkey coin whatever its state (records learn their policy from their bytes), and a pass heals HTLC coins an earlier pass freed
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js36
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