aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js36
-rw-r--r--tools/ark-htlc-held.test.js46
2 files changed, 79 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
diff --git a/tools/ark-htlc-held.test.js b/tools/ark-htlc-held.test.js
new file mode 100644
index 0000000..b2fd07e
--- /dev/null
+++ b/tools/ark-htlc-held.test.js
@@ -0,0 +1,46 @@
+// bun test tools/ark-htlc-held.test.js — a Lightning payment's HTLC vtxo is
+// never a coin to spend: the reconcile pass must not free it while its
+// payment is in flight (quick zaps: the second one picked the first one's
+// HTLC coin as its input and the server refused the policy), and _free()
+// refuses one however it is labelled.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+mock.module('../src/ark/proto.js', () => ({ ...proto, getVtxoStatus: async () => proto.VTXO_STATE_SPENDABLE }));
+const { ArkManager } = await import('../src/ark/manager.js');
+const make = () => {
+ const m = Object.create(ArkManager.prototype);
+ m.state = {
+ vtxos: [
+ { id: 'h', state: 'pending', amountSat: 23, bytes: 'x', policy: 'serverHtlcSend' }, // zap 1's HTLC, in flight
+ { id: 'c', state: 'pending', amountSat: 5, bytes: 'x', policy: 'pubkey' }, // a real orphan
+ { id: 'old', state: 'spendable', amountSat: 23, bytes: 'x', policy: 'serverHtlcSend' }, // freed by a pass before the fix, payment long done
+ { id: 'legacy', state: 'pending', amountSat: 7, bytes: 'x' }, // record from before the policy field
+ ],
+ actions: [
+ { type: 'ln-pay', step: 'initiated', parts: [{ inputId: 'in1' }], htlcVtxoIds: ['h'] },
+ { type: 'ln-pay', step: 'done', parts: [{ inputId: 'in0' }], htlcVtxoIds: ['old'] },
+ ],
+ scheduled: [],
+ };
+ m.arkUrl = 'x';
+ m._decoded = (v) => ({ point: { raw: new Uint8Array(36) }, policy: { type: v.id === 'legacy' ? 'serverHtlcSend' : 'pubkey' } });
+ m._keyForVtxo = () => ({ privkey: new Uint8Array(32) });
+ m._save = () => {}; m._movement = () => {}; m._remoteBusy = () => new Set();
+ return m;
+};
+test('an in-flight HTLC coin stays held through a reconcile pass', async () => {
+ const m = make();
+ await m.reconcile();
+ const st = Object.fromEntries(m.state.vtxos.map((v) => [v.id, v.state]));
+ expect(st.h).toBe('pending'); // held by its payment, not an orphan
+ expect(st.c).toBe('spendable'); // a real orphan is still freed
+ expect(st.old).toBe('spent'); // healed: its payment is done
+ expect(st.legacy).toBe('pending'); // learned its policy from the bytes, not freed
+ expect(m.state.vtxos[3].policy).toBe('serverHtlcSend');
+});
+test('_free never offers an HTLC coin, whatever state it carries', () => {
+ const m = make();
+ expect(m._free({ id: 'z', state: 'spendable', policy: 'serverHtlcSend' })).toBe(false);
+ expect(m._free({ id: 'z', state: 'spendable', policy: 'pubkey' })).toBe(true);
+ expect(m._free({ id: 'legacy', state: 'spendable', bytes: 'x' })).toBe(false); // decoded: serverHtlcSend
+});