aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-10 07:35:30 -0700
committerAdam Soltys <[email protected]>2026-11-10 07:35:34 -0700
commit520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf (patch)
tree46d1fcb8c9c13cf62cf3bb7decd35906e3e2749e /src/ark/manager.js
parentf6970a3be5eb1019675debe47b0eedd77892a933 (diff)
downloadcoinosv3-520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf.tar.gz
coinosv3-520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf.zip
A zap answered over NWC waits on far fewer round trips, and a phone with Coinos behind another app answers from its worker. "I'm on V3 … I find nwc zaps a lot more slower than usual" (398ja, 2026-11-10). Measured from the notifier's wakes and the ASP's log over four days: a closed app took about six seconds from the push to its cosign (the Lightning part after that is one and a half), an Android phone fifteen at the median, up to thirty. Where it went: every call to the ASP and the notifier was two round trips (no Access-Control-Max-Age, so Chrome re-asked the CORS preflight after five seconds; a Cloudflare response-header rule now sets 7200 on OPTIONS for ark.coinos.io, ark-staging and nwcpush), and the worker made them one after another: answered?, GetArkInfo, Handshake, claim, a save, the chain tip, then the payment, then status ("pending"), the tip again, the held status call, and status once more for the preimage it had just been handed. Now: what the mirror alone decides (invoice, network, budgets, coins, keys) comes first with no network; the claim (which now also says "already answered", coinosv3-server 3c5e9c3) goes out beside the manager's start, whose info call, handshake and tip run together (init, warmTip/_tipFresh); a send just initiated returns to its caller instead of asking at once, and awaitLnPay settles from the held call's own answer. A worker-style send on regtest: eight calls, six in sequence, where there were thirteen. The app's own answer no longer waits a second on a phone that is on screen (only one behind another app holds back for a computer).
Android freezes an app behind another one, and the worker's nudge to it sat unread until it was opened: on a phone the worker now hands a request only to a window on screen (as it already did on iOS), nudges a hidden one in case it is awake, and answers itself; the request's lock decides between them. For that the mirror is no longer written over the worker's hand: the worker stamps its writes (workerAt), the app writes under the worker's own lock (nwc-bg-work) and folds the worker's state in first (never mid-payment), carries the stamp, absorbs it when it comes back on screen (arkBgAbsorb), and refreshes the mirror as it goes behind another app. Tests: ln-pay-double-wake (the one held call settles; a failed or mismatched answer doesn't), sw-push-quota (an Android phone's hidden window), nwc-sw-test's notifier stand-in answers /claim as the real one; tools/ark/ln-e2e-test's pay half against regtest captaind 0.8 + CLN Co-Authored-By: Claude Fable 5.2 <[email protected]>
Diffstat (limited to 'src/ark/manager.js')
-rw-r--r--src/ark/manager.js43
1 files changed, 39 insertions, 4 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 0370a77..8aa8707 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -310,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;
@@ -323,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);
@@ -387,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 {
@@ -1421,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);
}
@@ -1556,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.
@@ -1721,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