diff options
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/manager.js | 109 | ||||
| -rw-r--r-- | src/ark/store.js | 20 |
2 files changed, 104 insertions, 25 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 853ca84..8aa8707 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 @@ -307,6 +310,9 @@ export class ArkManager { seen.add(m.vtxoId); return true; }); + // (the handshake goes out beside the info call, not after it: a wake of + // the phone's worker pays for every round trip in the zap it answers) + const hsP = handshake(this.arkUrl).catch(() => null); try { this.info = await getArkInfo(this.arkUrl); this.offline = false; @@ -320,7 +326,7 @@ export class ArkManager { this.info = this.infoHint; this.offline = true; } - const hs = await handshake(this.arkUrl).catch(() => null); + const hs = await hsP; this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); @@ -384,6 +390,20 @@ export class ArkManager { return Date.now() - age * 600_000; } + // The tip for a payment about to be built: one asked for moments ago (a + // worker warms it while the manager starts) is as good as asking again. + warmTip() { + const p = this.chain.tipHeight().then((h) => { this._tipH = h; this._tipAt = Date.now(); this._tipNow = Date.now(); return h; }); + this._tipWarm = p; + p.catch(() => {}).finally(() => { if (this._tipWarm === p) this._tipWarm = null; }); + return p; + } + async _tipFresh() { + if (this._tipNow && Date.now() - this._tipNow < 5000 && this._tipH) return this._tipH; + if (this._tipWarm) { try { return await this._tipWarm; } catch { /* ask again */ } } + return this.chain.tipHeight(); + } + async _tipMemo() { if (this._tipAt && Date.now() - this._tipAt < 60_000) return this._tipH; try { @@ -1143,10 +1163,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 +1196,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)) @@ -1386,10 +1438,17 @@ export class ArkManager { async awaitLnPay(id, timeoutMs = 90_000) { const a = this.lnAction(id); if (!a || a.step !== 'initiated') return this.driveLn(id); - await Promise.race([ + const res = await Promise.race([ checkLightningPayment(this.arkUrl, hex.decode(a.paymentHash), true).catch(() => null), new Promise((r) => setTimeout(r, timeoutMs)), ]); + // the held call's own answer settles it: asking once more for the same + // preimage was a round trip between the payment and its reply + if (res && res.status === 'success' && res.preimage && a.step === 'initiated' + && hex.encode(sha256(hex.decode(res.preimage))) === a.paymentHash) { + this._settleLnPay(a, res.preimage); + return a; + } return this.driveLn(id); } @@ -1521,8 +1580,14 @@ export class ArkManager { // as paid, hours before the money moved) throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); } - const tip = await this.chain.tipHeight(); - const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); + const [tip, routing] = await Promise.all([ + this._tipFresh(), + routingFeeSat != null ? routingFeeSat : this.lnRouteFee(invoice, amountSat), + ]); + // the two awaits above let a second call for the same invoice in + const twin = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (twin) throw new Error(twin.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); // smallest single vtxo that covers amount + routing + its expiry-dependent // fee — else gather largest-first: each input funds its own HTLC vtxo with // the same payment hash, and the server sums them into one payment. @@ -1530,20 +1595,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) => { @@ -1699,6 +1751,11 @@ export class ArkManager { }); action.step = 'initiated'; this._save(); + // Just handed to the server: nothing to learn from asking this + // instant (it answered "pending", and the chain tip was fetched to + // go with it — two round trips before the caller's held call could + // even start). The caller waits on it (awaitLnPay) or polls. + return; } catch (e) { // the HTLC already went to this payment: another context (a second // wake of the phone's worker, seconds apart, over its own copy of the @@ -1848,9 +1905,11 @@ export class ArkManager { }); } catch (e) { // "already been paid" for a settled one; "UNIQUE constraint failed: - // invoices.payment_hash" for one merely issued before — both mean - // this index is spoken for - const used = /already been paid|already exists|already in use|duplicate|UNIQUE constraint|payment_hash/i.test(e?.message || ''); + // invoices.payment_hash" for one merely issued before; "the invoice + // for payment hash … was canceled" for one a cancel retired (a + // withdraw-link claim died on that, 2026-11-08) — all mean this + // index is spoken for + const used = /already been paid|already exists|already in use|duplicate|UNIQUE constraint|payment_hash|payment hash [0-9a-f]+ was cancel+ed/i.test(e?.message || ''); if (!used || tries >= 200) throw e; this._save(); // remember the skipped index even if the next try fails continue; diff --git a/src/ark/store.js b/src/ark/store.js index b1caa94..24d8f55 100644 --- a/src/ark/store.js +++ b/src/ark/store.js @@ -139,6 +139,7 @@ export class ArkStore { } } this.ready = true; + this._parsed = null; // peeks now read the opened store if (migrated.length) { this.flush().then(() => { for (const k of migrated) { try { ls.removeItem(k); } catch {} } }).catch(() => {}); } @@ -153,13 +154,32 @@ export class ArkStore { const v = this.mem.get(key); return v ? clone(v) : null; } + // The stored object itself, not a copy — for readers that only look (the + // render path). get() deep-copies, and a big wallet's state is ~1 MB: on a + // Pixel 9 each copy took ~220 ms and the first render asked five to seven + // times before the manager connected, 1.4 s of every launch. Never mutate + // what this returns. Before the store opens (or on localStorage) the parse + // is kept until the key is next written. + peek(key) { + if (!this.ready || this.lsMode) { + const hit = this._parsed && this._parsed.get(key); + if (hit !== undefined) return hit; + let v = null; + try { const raw = this.ls().getItem(key); v = raw ? JSON.parse(raw) : null; } catch { v = null; } + (this._parsed ||= new Map()).set(key, v); + return v; + } + return this.mem.get(key) || null; + } set(key, obj) { + if (this._parsed) this._parsed.delete(key); if (!this.ready || this.lsMode) { try { this.ls().setItem(key, JSON.stringify(obj)); } catch {} return; } this.mem.set(key, obj); this.dirty.add(key); this._schedule(); } remove(key) { + if (this._parsed) this._parsed.delete(key); if (!this.ready || this.lsMode) { try { this.ls().removeItem(key); } catch {} return; } this.mem.delete(key); this.dirty.add(key); |
