aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 08:45:27 -0700
committerAdam Soltys <[email protected]>2026-10-29 16:40:36 -0700
commitabb0f094aa51155cec4561b4999f6739df47f23e (patch)
tree14547b0c82689a99f4033b0fc3577471f9ab9d48
parentc7c0bcdb552a1e8f080abdf299e544d82ce9882a (diff)
downloadcoinos-server-abb0f094aa51155cec4561b4999f6739df47f23e.tar.gz
coinos-server-abb0f094aa51155cec4561b4999f6739df47f23e.zip
lnquote: keep the quote layer until a timed-out getroutes is done
askrene queues getroutes calls beyond its child limit and only checks layer names when they arrive. Removing the quote layer after our RPC timeout left a queued request pointing at a missing layer, which trips an assert that takes lightningd down. Delay the removal when getroutes never answered. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--lib/lnquote.ts29
1 files changed, 24 insertions, 5 deletions
diff --git a/lib/lnquote.ts b/lib/lnquote.ts
index de96f69a..bc34b3fb 100644
--- a/lib/lnquote.ts
+++ b/lib/lnquote.ts
@@ -22,6 +22,9 @@ const FAKE_NODE =
const XPAY_MAXDELAY = 2016; // xpay's default maxdelay
+// How long a quote layer outlives a getroutes call that timed out on our side
+const LAYER_REMOVE_DELAY = 10 * 60_000;
+
// Extra layers sendLightning passes to xpay. xpay forwards user layers to
// getroutes verbatim, and getroutes rejects an unknown layer outright, so only
// pass the ones that actually exist on this node (prod has prefer-kappa,
@@ -173,6 +176,12 @@ export const quoteRoutingFee = async ({
}
const layer = `quote-${v4()}`;
+ // True while a getroutes on this layer may still be queued inside askrene.
+ // askrene runs at most 4 route computations at once and checks layer names
+ // only on arrival; a queued request whose layer was removed in the meantime
+ // trips an assert in apply_layers that aborts lightningd. When our RPC times
+ // out the request is still in that queue, so the layer has to outlive it.
+ let routing = false;
try {
await ln.call("askrene-create-layer", { layer, persistent: false });
for (const f of fakes) {
@@ -195,6 +204,7 @@ export const quoteRoutingFee = async ({
});
}
+ routing = true;
const r = await ln.call("getroutes", {
source: me,
destination,
@@ -211,7 +221,12 @@ export const quoteRoutingFee = async ({
maxfee_msat: cap,
final_cltv: finalCltv,
maxdelay: XPAY_MAXDELAY,
+ }).catch((e) => {
+ // Any reply from askrene, error or not, means it is done with the layer
+ if (e?.code !== "ETIMEDOUT") routing = false;
+ throw e;
});
+ routing = false;
const routes: any[] = r?.routes ?? [];
if (!routes.length) fail("No route found");
@@ -232,11 +247,15 @@ export const quoteRoutingFee = async ({
const feeMsat = Math.max(0, sent - delivered);
return { fee: Math.ceil(feeMsat / 1000), feeMsat, parts: routes.length };
} finally {
- try {
- await ln.call("askrene-remove-layer", { layer });
- } catch (e: any) {
- warn("askrene-remove-layer failed", layer, e?.message ?? String(e));
- }
+ const remove = async () => {
+ try {
+ await ln.call("askrene-remove-layer", { layer });
+ } catch (e: any) {
+ warn("askrene-remove-layer failed", layer, e?.message ?? String(e));
+ }
+ };
+ if (routing) setTimeout(remove, LAYER_REMOVE_DELAY);
+ else await remove();
}
};