diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 23:34:31 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 23:34:31 -0700 |
| commit | a89bce97319d5860da8d581ff41cdace9acbb077 (patch) | |
| tree | 263363716c53cfa1ec2cf7a984a08568690cb48e | |
| parent | 2d1f83c88000503b0ce5dbe391ce1929bcb9a71b (diff) | |
| download | coinosv3-a89bce97319d5860da8d581ff41cdace9acbb077.tar.gz coinosv3-a89bce97319d5860da8d581ff41cdace9acbb077.zip | |
Automatic renewals only when worth it (fee under a tenth of what renews); orphan HTLC coins are settled by the server's answer
Adam's wallet renewed a 1,000-sat coin past its expiry plus four bits of change for 301 sats — the expiry trigger had no floor, only the depth trigger did. Now every automatic renewal, under either renewing strategy, is skipped when the fee would exceed a tenth of the coins it renews; they stay spendable, the server honors them, and the strategy card says so.
Reconcile: five 22-sat HTLC-send coins sat 'pending' for days — their Lightning payments' actions had been pruned, and an unheld pending coin was only asked about when it was a plain pubkey coin. HTLC orphans are asked about now too: spent is spent; one the server still holds open stays pending (an HTLC coin is never this wallet's to spend). tools/ark-htlc-orphan.test.js.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/ark/manager.js | 11 | ||||
| -rw-r--r-- | src/features/ark.js | 9 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | tools/ark-htlc-orphan.test.js | 33 |
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]); +}); |
