aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/ark/manager.js')
-rw-r--r--src/ark/manager.js109
1 files changed, 84 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;