diff options
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/manager.js | 58 |
1 files changed, 40 insertions, 18 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 853ca84..91b5f0a 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -236,7 +236,7 @@ export function renewPolicy(tier, network) { } export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null, lnOldestFirst }) { this.account = account; // HDKey node; ark keys derived beneath it // The server's last published info, kept by the app: lets the wallet open // when the server can't be reached (see init) @@ -258,6 +258,9 @@ export class ArkManager { : network === 'mutinynet' ? 'https://names.halwallet.app/lnquote?network=mutinynet' : null; this.onUpdate = onUpdate || (() => {}); + // () => true when this wallet renews its own coins (custody tier self or + // exit): a Lightning send then spends the coins nearest expiry first + this.lnOldestFirst = lnOldestFirst || (() => false); this.state = null; this.info = null; this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push @@ -1143,10 +1146,10 @@ export class ArkManager { const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp); return [...live, ...past]; } - // largest-first until `need` is covered; the coins still inside their + // largest-first (or in `order`) until `need` is covered; the coins inside their // timelock alone if they can carry it, else everything - _accumulate(spendable, tip, need) { - const desc = (a, b) => b.amountSat - a.amountSat; + _accumulate(spendable, tip, need, order) { + const desc = order || ((a, b) => b.amountSat - a.amountSat); const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc); for (const pool of [live, [...spendable].sort(desc)]) { const picked = []; @@ -1176,6 +1179,38 @@ export class ArkManager { return picked; } + // Inputs for a Lightning send of `amountSat` plus `routing`, and the fee + // they come to: { inputs, feeSat }. Throws when the coins can't cover it. + _selectLnInputs(spendable, tip, amountSat, routing) { + let inputs = null, feeSat = 0; + // A wallet that renews its own coins pays for every renewal, so it + // spends the coins nearest expiry first (Stacker News, 2026-10-29): the + // ASP takes them, so nobody downstream inherits the short timelock, as + // an Ark-to-Ark send's recipient would. Otherwise (and on a tie) the + // smallest coin that covers it, keeping the balance in few coins. + const oldest = !!this.lnOldestFirst(); + const exp = (v) => v.expiryHeight || Infinity; + const bySize = (a, b) => a.amountSat - b.amountSat; + const byAge = (a, b) => (exp(a) - exp(b)) || bySize(a, b); + for (const v of this._spendOrder(spendable, tip, oldest ? byAge : bySize)) { + const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; + if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } + } + if (!inputs) { + const need = (set, sum) => { + feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; + return sum >= amountSat + feeSat; + }; + let picked = oldest ? this._accumulate(spendable, tip, need, byAge) : null; + // too many old crumbs for one payment: fall back to largest-first + if (!picked || picked.length > MAX_SEND_INPUTS) picked = this._accumulate(spendable, tip, need); + if (picked) inputs = picked; + if (!inputs) throw new Error(this._insufficientMsg(tip)); + if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); + } + return { inputs, feeSat }; + } + _insufficientMsg(tip) { const expiredSat = this.state.vtxos .filter((v) => v.state === 'spendable' && this._expired(v, tip)) @@ -1530,20 +1565,7 @@ export class ArkManager { // a coin at the depth cap can't be an input: the ASP refuses the cosign const spendable = this.state.vtxos .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v)); - let inputs = null, feeSat = 0; - for (const v of this._spendOrder(spendable, tip, (a, b) => a.amountSat - b.amountSat)) { - const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; - if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } - } - if (!inputs) { - const picked = this._accumulate(spendable, tip, (set, sum) => { - feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; - return sum >= amountSat + feeSat; - }); - if (picked) inputs = picked; - if (!inputs) throw new Error(this._insufficientMsg(tip)); - if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); - } + const { inputs, feeSat } = this._selectLnInputs(spendable, tip, amountSat, routing); // per-input HTLC share: first n-1 consumed whole, the last carries change let remainingLock = amountSat + feeSat; const parts = inputs.map((v) => { |
