aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js11
-rw-r--r--src/features/ark.js9
-rw-r--r--src/i18n.js2
-rw-r--r--tools/ark-htlc-orphan.test.js33
4 files changed, 50 insertions, 5 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 79fdcd0..df199af 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -825,8 +825,13 @@ export class ArkManager {
v.state = owner.step === 'done' || owner.step === 'failed' ? 'spent' : 'pending';
changed = true;
}
+ // An HTLC coin whose payment no action names any more (done ln-pays are
+ // pruned after a while) is an orphan too: five 22-sat ones sat 'pending'
+ // in a wallet for days, counted as money on its way. The server's answer
+ // settles it — spent is spent; one still open stays pending, since an
+ // HTLC coin is never this wallet's to spend (see below).
const candidates = this.state.vtxos.filter((v) => v.state === 'spendable'
- || (v.state === 'pending' && !held.has(v.id) && v.bytes && this._policyType(v) === 'pubkey'));
+ || (v.state === 'pending' && !held.has(v.id) && v.bytes));
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
@@ -851,8 +856,8 @@ export class ArkManager {
// an orphan the server still counts is simply spendable again; one it
// holds as spent is gone, and the pay that took it was recorded (or
// will merge in) elsewhere — no row of its own
- if (states[i] === VTXO_STATE_SPENDABLE) { v.state = 'spendable'; changed = true; }
- else if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ else if (states[i] === VTXO_STATE_SPENDABLE && this._policyType(v) === 'pubkey') { v.state = 'spendable'; changed = true; }
return;
}
if (states[i] !== VTXO_STATE_SPENT) return;
diff --git a/src/features/ark.js b/src/features/ark.js
index 771fa45..6952fc0 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -3437,13 +3437,19 @@ export function arkFeature(ctx) {
const deepCoins = !policy.maxDepth ? [] : spendables.filter((v) =>
!expiringCoins.includes(v) && depthOf(v) >= policy.maxDepth && mgr.refreshFee([v], tip) * 10 <= v.amountSat);
if (!expiringCoins.length && !deepCoins.length) { arkRenewWarn = null; return; }
+ // Worth it, or not at all: an automatic renewal never spends more than
+ // a tenth of what it renews. 301 sats to renew a 1,000-sat coin and
+ // four bits of change restored an exit nobody would ever use (the
+ // exit itself would cost more than the coins). Coins left alone stay
+ // spendable — the server keeps honoring them.
+ const worthIt = (set) => mgr.refreshFee(set, tip) * 10 <= set.reduce((n, v) => n + v.amountSat, 0);
if (!expiringCoins.length) {
// nothing is expiring: this round is for the deep coins alone, and
// they pay for it — the floor logic below still applies
arkRenewWarn = null;
let renewSet = [...deepCoins, ...spendables.filter((v) => !deepCoins.includes(v) && v.amountSat < 1000)];
const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
- if (netSat(renewSet) < 330) return;
+ if (netSat(renewSet) < 330 || !worthIt(renewSet)) return;
await mgr.refresh(renewSet.map((v) => v.id));
render();
return;
@@ -3478,6 +3484,7 @@ export function arkFeature(ctx) {
const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()];
if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it
+ if (!worthIt(renewSet)) return; // not worth a tenth of itself: left alone, still spendable
await mgr.refresh(renewSet.map((v) => v.id));
render();
} catch {} // transient — the next throttled attempt retries
diff --git a/src/i18n.js b/src/i18n.js
index b8d9f45..edf9102 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -787,7 +787,7 @@ const STR = {
arkCustodyCustodial: 'Allow coins to expire',
arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation.',
arkCustodySelf: 'Minimize renewal cost',
- arkCustodySelfDesc: 'Coins renew in the last hours before they expire, when renewal is cheapest, so you can always exit on your own without Coinos. Roughly once every {days} days per coin.',
+ arkCustodySelfDesc: 'Coins renew in the last hours before they expire, when renewal is cheapest, so you can always exit on your own without Coinos. Roughly once every {days} days per coin. Coins too small to be worth the fee are left alone.',
arkCustodyExit: 'Minimize exit cost',
arkCustodyExitDesc: 'Every transfer adds a hop to a coin’s history, and a unilateral exit pays one on-chain transaction per hop. This renews a coin before it expires, and also as soon as its history is {hops} hops deep, so bringing any coin on-chain stays close to a fresh coin’s price. Only for coins worth it: never more than a tenth of a coin on its renewal.',
arkCustodyCostNone: 'no fees',
diff --git a/tools/ark-htlc-orphan.test.js b/tools/ark-htlc-orphan.test.js
new file mode 100644
index 0000000..cc127be
--- /dev/null
+++ b/tools/ark-htlc-orphan.test.js
@@ -0,0 +1,33 @@
+// bun test tools/ark-htlc-orphan.test.js — an HTLC coin whose payment's
+// action has been pruned is an orphan the server settles: spent means spent
+// (it stopped counting as pending money), still-open stays pending, and a
+// coin a live payment holds is never asked about.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+const asked = [];
+mock.module('../src/ark/proto.js', () => ({ ...proto, getVtxoStatus: async (url, raw) => { asked.push(raw[0]); return raw[0] === 1 ? proto.VTXO_STATE_SPENT : proto.VTXO_STATE_SPENDABLE; } }));
+const { ArkManager } = await import('../src/ark/manager.js');
+test('orphan HTLC coins are settled by the server\'s answer', async () => {
+ const m = Object.create(ArkManager.prototype);
+ m.state = {
+ vtxos: [
+ { id: 'gone', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 1 }, // payment done long ago, action pruned: server says spent
+ { id: 'open', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 2 }, // orphan the server still holds open
+ { id: 'held', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 3 }, // a live payment's: not asked about
+ { id: 'coin', state: 'pending', amountSat: 500, bytes: 'x', policy: 'pubkey', tag: 2 }, // a plain orphan the server counts: spendable again
+ ],
+ actions: [{ type: 'ln-pay', step: 'initiated', parts: [{ inputId: 'in1' }], htlcVtxoIds: ['held'] }],
+ scheduled: [],
+ };
+ m.arkUrl = 'x';
+ m._decoded = (v) => ({ point: { raw: Uint8Array.of(v.tag) }, policy: { type: v.policy } });
+ m._keyForVtxo = () => ({ privkey: new Uint8Array(32) });
+ m._save = () => {}; m._movement = () => {}; m._remoteBusy = () => new Set();
+ await m.reconcile();
+ const st = Object.fromEntries(m.state.vtxos.map((v) => [v.id, v.state]));
+ expect(st.gone).toBe('spent');
+ expect(st.open).toBe('pending');
+ expect(st.held).toBe('pending');
+ expect(st.coin).toBe('spendable');
+ expect(asked.sort()).toEqual([1, 2, 2]);
+});