diff options
| author | Adam Soltys <[email protected]> | 2026-08-18 01:11:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-18 01:11:17 -0700 |
| commit | 1a51f71a84ff661dd0ec271ef3bc0194545b27c4 (patch) | |
| tree | 5ee70b9648e96a5ea1e0b3c29426de117ad144fa | |
| parent | 4069f813820f1814bef5b93c679619bc7ed8ddba (diff) | |
| download | coinosv3-1a51f71a84ff661dd0ec271ef3bc0194545b27c4.tar.gz coinosv3-1a51f71a84ff661dd0ec271ef3bc0194545b27c4.zip | |
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
| -rw-r--r-- | src/ark/manager.js | 16 |
1 files changed, 15 insertions, 1 deletions
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 |
