From 1a51f71a84ff661dd0ec271ef3bc0194545b27c4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 18 Aug 2026 01:11:17 -0700 Subject: The HTLC expiry stops trusting a cached clock: tip fetches bypass the CDN, and when the server names its own tip the payment adopts it and retries instead of dying Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz --- src/ark/manager.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) (limited to 'src/ark') diff --git a/src/ark/manager.js b/src/ark/manager.js index 3a73e0b..eb23b51 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -190,7 +190,9 @@ export class ArkManager { get chain() { const base = this.esploraUrl; return { - tipHeight: async () => Number(await fetch(`${base}/blocks/tip/height`).then((r) => r.text())), + // no-store: a CDN-cached tip lags a 30s-block network by many blocks, + // and a stale tip under-shoots HTLC expiries the server then rejects + tipHeight: async () => Number(await fetch(`${base}/blocks/tip/height`, { cache: 'no-store' }).then((r) => r.text())), getTxStatus: async (txid) => { const r = await fetch(`${base}/tx/${txid}/status`); if (r.ok) return r.json(); @@ -835,6 +837,18 @@ export class ArkManager { resps = await requestLightningPayHtlcCosign(this.arkUrl, builds.map((b, i) => cosignPartBytes({ build: b, input: decoded[i], vtxoKeys: keysList[i], nonces: noncesList[i] }))); } catch (e) { + // A stale local tip under-shoots the HTLC expiry, and the server's + // error helpfully names ITS tip — adopt it and let the next drive + // retry with a valid expiry instead of failing the whole payment. + const tipM = e instanceof GrpcError && e.message.match(/expiry is too low.*?our tip is (\d+)/i); + if (tipM) { + const serverTip = parseInt(tipM[1], 10); + action.htlcExpiry = serverTip + (this.info.htlcSendExpiryDelta || 258); + this._tipH = Math.max(this._tipH || 0, serverTip); + this._tipAt = Date.now(); + this._save(); + throw new Error('chain tip was stale — retrying with the corrected expiry'); + } // INVALID_ARGUMENT (status 3): the server rejected the package outright // — nothing was spent, everything returns to spendable. "already // spent" names ONE stale input but not which: free them all and let -- cgit v1.2.3