Skip to content

Commit eb53049

Browse files
committed
fix: fix rgs url and check rgs health
1 parent 54ffcd3 commit eb53049

File tree

3 files changed

+250
-2
lines changed

3 files changed

+250
-2
lines changed

__tests__/lightning.test.ts

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { IBtInfo, IGetFeeEstimatesResponse } from 'beignet';
2-
import { getFees } from '../src/utils/lightning';
2+
import { checkRgsHealth, getFees } from '../src/utils/lightning';
33

44
jest.mock('../src/utils/wallet', () => ({
55
getSelectedNetwork: jest.fn(() => 'bitcoin'),
66
}));
77

8+
jest.mock('../src/store/helpers', () => ({
9+
getStore: jest.fn(() => ({
10+
settings: {
11+
rapidGossipSyncUrl: 'https://rgs.blocktank.to/snapshots/',
12+
},
13+
})),
14+
}));
15+
816
describe('getFees', () => {
917
const MEMPOOL_URL = 'https://mempool.space/api/v1/fees/recommended';
1018
const BLOCKTANK_URL = 'https://api1.blocktank.to/api/info';
@@ -206,3 +214,170 @@ describe('getFees', () => {
206214
});
207215
});
208216

217+
describe('checkRgsHealth', () => {
218+
const RGS_URL = 'https://rgs.blocktank.to/snapshots/';
219+
220+
beforeEach(() => {
221+
jest.clearAllMocks();
222+
});
223+
224+
it('should detect healthy RGS (< 24 hours old)', async () => {
225+
const nowSeconds = Math.floor(Date.now() / 1000);
226+
const recentTimestamp = nowSeconds - 3600 * 12; // 12 hours ago
227+
228+
const mockHtml = `
229+
<a href="snapshot__calculated-at%3A${recentTimestamp}__range%3A10800-scope.lngossip">snapshot</a>
230+
`;
231+
232+
(global.fetch as jest.Mock) = jest.fn(() =>
233+
Promise.resolve({
234+
ok: true,
235+
text: () => Promise.resolve(mockHtml),
236+
}),
237+
);
238+
239+
const result = await checkRgsHealth();
240+
241+
expect(result.isOk()).toBe(true);
242+
if (result.isOk()) {
243+
expect(result.value.isHealthy).toBe(true);
244+
expect(result.value.timestamp).toBe(recentTimestamp);
245+
expect(result.value.ageHours).toBeGreaterThan(11);
246+
expect(result.value.ageHours).toBeLessThan(13);
247+
}
248+
expect(fetch).toHaveBeenCalledWith(RGS_URL);
249+
});
250+
251+
it('should detect stale RGS (> 24 hours old)', async () => {
252+
const nowSeconds = Math.floor(Date.now() / 1000);
253+
const staleTimestamp = nowSeconds - 3600 * 48; // 48 hours ago (2 days)
254+
255+
const mockHtml = `
256+
<a href="snapshot__calculated-at%3A${staleTimestamp}__range%3A10800-scope.lngossip">snapshot</a>
257+
`;
258+
259+
(global.fetch as jest.Mock) = jest.fn(() =>
260+
Promise.resolve({
261+
ok: true,
262+
text: () => Promise.resolve(mockHtml),
263+
}),
264+
);
265+
266+
const result = await checkRgsHealth();
267+
268+
expect(result.isOk()).toBe(true);
269+
if (result.isOk()) {
270+
expect(result.value.isHealthy).toBe(false);
271+
expect(result.value.timestamp).toBe(staleTimestamp);
272+
expect(result.value.ageHours).toBeGreaterThan(47);
273+
expect(result.value.ageHours).toBeLessThan(49);
274+
}
275+
});
276+
277+
it('should handle RGS endpoint returning non-200 status', async () => {
278+
(global.fetch as jest.Mock) = jest.fn(() =>
279+
Promise.resolve({
280+
ok: false,
281+
status: 404,
282+
}),
283+
);
284+
285+
const result = await checkRgsHealth();
286+
287+
expect(result.isErr()).toBe(true);
288+
if (result.isErr()) {
289+
expect(result.error.message).toContain('404');
290+
}
291+
});
292+
293+
it('should handle missing timestamp in RGS HTML', async () => {
294+
const mockHtml = `
295+
<a href="some-file-without-timestamp.lngossip">snapshot</a>
296+
`;
297+
298+
(global.fetch as jest.Mock) = jest.fn(() =>
299+
Promise.resolve({
300+
ok: true,
301+
text: () => Promise.resolve(mockHtml),
302+
}),
303+
);
304+
305+
const result = await checkRgsHealth();
306+
307+
expect(result.isErr()).toBe(true);
308+
if (result.isErr()) {
309+
expect(result.error.message).toContain('Could not parse');
310+
}
311+
});
312+
313+
it('should handle network timeout', async () => {
314+
jest.useFakeTimers();
315+
316+
(global.fetch as jest.Mock) = jest.fn(() =>
317+
new Promise(resolve => {
318+
setTimeout(() => resolve({
319+
ok: true,
320+
text: () => Promise.resolve('<html></html>'),
321+
}), 10000); // longer than 5s timeout
322+
}),
323+
);
324+
325+
const healthPromise = checkRgsHealth();
326+
327+
jest.advanceTimersByTime(6000);
328+
329+
await expect(healthPromise).resolves.toMatchObject({
330+
isErr: expect.any(Function),
331+
});
332+
333+
const result = await healthPromise;
334+
expect(result.isErr()).toBe(true);
335+
336+
jest.useRealTimers();
337+
});
338+
339+
it('should parse timestamp with colon delimiter', async () => {
340+
const testTimestamp = 1762462800; // Nov 6, 2025
341+
342+
const mockHtml = `
343+
<a href="snapshot__calculated-at:${testTimestamp}__range:10800-scope.lngossip">snapshot</a>
344+
`;
345+
346+
(global.fetch as jest.Mock) = jest.fn(() =>
347+
Promise.resolve({
348+
ok: true,
349+
text: () => Promise.resolve(mockHtml),
350+
}),
351+
);
352+
353+
const result = await checkRgsHealth();
354+
355+
expect(result.isOk()).toBe(true);
356+
if (result.isOk()) {
357+
expect(result.value.timestamp).toBe(testTimestamp);
358+
}
359+
});
360+
361+
it('should parse timestamp with percent-encoded colon', async () => {
362+
const testTimestamp = 1762462800; // Nov 6, 2025
363+
364+
const mockHtml = `
365+
<a href="snapshot__calculated-at%3A${testTimestamp}__range%3A10800-scope.lngossip">snapshot</a>
366+
`;
367+
368+
(global.fetch as jest.Mock) = jest.fn(() =>
369+
Promise.resolve({
370+
ok: true,
371+
text: () => Promise.resolve(mockHtml),
372+
}),
373+
);
374+
375+
const result = await checkRgsHealth();
376+
377+
expect(result.isOk()).toBe(true);
378+
if (result.isOk()) {
379+
expect(result.value.timestamp).toBe(testTimestamp);
380+
}
381+
});
382+
});
383+

src/store/shapes/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export const initialSettingsState: TSettings = {
103103
selectedCurrency: getDefaultCurrency(),
104104
selectedLanguage: 'english',
105105
customElectrumPeers: defaultElectrumPeer,
106-
rapidGossipSyncUrl: 'https://rgs.blocktank.to/snapshot/',
106+
rapidGossipSyncUrl: 'https://rgs.blocktank.to/snapshots/',
107107
coinSelectAuto: true,
108108
coinSelectPreference: ECoinSelectPreference.small,
109109
receivePreference: defaultReceivePreference,

src/utils/lightning/index.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,50 @@ const getScriptPubKeyHistory = async (
252252
return await electrum.getScriptPubKeyHistory(scriptPubKey);
253253
};
254254

255+
/**
256+
* Checks if the RGS (Rapid Gossip Sync) endpoint is healthy and returning recent data.
257+
* @returns {Promise<Result<{ isHealthy: boolean; timestamp: number; ageHours: number }>>}
258+
*/
259+
export const checkRgsHealth = async (): Promise<
260+
Result<{ isHealthy: boolean; timestamp: number; ageHours: number }>
261+
> => {
262+
try {
263+
const rgsUrl = getStore().settings.rapidGossipSyncUrl;
264+
265+
// Fetch the RGS directory listing to find latest snapshot
266+
// @ts-ignore - fetch is available globally in React Native
267+
const response: any = await promiseTimeout(5000, fetch(rgsUrl));
268+
269+
if (!response.ok) {
270+
return err(`RGS endpoint returned status ${response.status}`);
271+
}
272+
273+
const html: string = await response.text();
274+
275+
// Extract timestamp from snapshot filenames (format: snapshot__calculated-at:TIMESTAMP__)
276+
const timestampMatch = html.match(/calculated-at[:%](\d{10})/);
277+
if (!timestampMatch) {
278+
return err('Could not parse RGS snapshot timestamp');
279+
}
280+
281+
const timestamp = Number.parseInt(timestampMatch[1], 10);
282+
const now = Math.floor(Date.now() / 1000);
283+
const ageSeconds = now - timestamp;
284+
const ageHours = ageSeconds / 3600;
285+
286+
// Consider RGS healthy if snapshot is less than 24 hours old
287+
const isHealthy = ageHours < 24;
288+
289+
return ok({
290+
isHealthy,
291+
timestamp,
292+
ageHours,
293+
});
294+
} catch (e) {
295+
return err(`RGS health check failed: ${e.message}`);
296+
}
297+
};
298+
255299
/**
256300
* Fetch fees from mempool.space and blocktank.to, prioritizing mempool.space.
257301
* Multiple attempts are made to fetch the fees from each provider
@@ -266,6 +310,7 @@ export const getFees: TGetFees = async () => {
266310

267311
// fetch, validate and map fees from mempool.space to IOnchainFees
268312
const fetchMp = async (): Promise<IOnchainFees> => {
313+
// @ts-ignore - fetch is available globally in React Native
269314
const f1 = await fetch('https://mempool.space/api/v1/fees/recommended');
270315
const j: IGetFeeEstimatesResponse = await f1.json();
271316
if (
@@ -290,6 +335,7 @@ export const getFees: TGetFees = async () => {
290335

291336
// fetch, validate and map fees from Blocktank to IOnchainFees
292337
const fetchBt = async (): Promise<IOnchainFees> => {
338+
// @ts-ignore - fetch is available globally in React Native
293339
const f2 = await fetch('https://api1.blocktank.to/api/info');
294340
const j: IBtInfo = await f2.json();
295341
if (
@@ -510,6 +556,33 @@ export const setupLdk = async ({
510556
updateLightningNodeVersionThunk(),
511557
addTrustedPeers(),
512558
]);
559+
560+
// Check RGS health and warn if stale (only on mainnet)
561+
if (selectedNetwork === 'bitcoin') {
562+
const rgsHealth = await checkRgsHealth();
563+
if (rgsHealth.isOk()) {
564+
const { isHealthy, ageHours, timestamp } = rgsHealth.value;
565+
const ageFormatted = ageHours.toFixed(1);
566+
const dateFormatted = new Date(timestamp * 1000).toISOString();
567+
568+
if (!isHealthy) {
569+
console.warn(
570+
`⚠️ RGS sync is stale: ${ageFormatted} hours old (last sync: ${dateFormatted})`,
571+
);
572+
await ldk.writeToLogFile(
573+
'warn',
574+
`RGS sync is ${ageFormatted} hours old (last sync: ${dateFormatted}). This may cause payment routing failures due to outdated channel fees.`,
575+
);
576+
} else {
577+
console.log(
578+
`✓ RGS sync is healthy: ${ageFormatted} hours old (last sync: ${dateFormatted})`,
579+
);
580+
}
581+
} else {
582+
console.warn(`RGS health check failed: ${rgsHealth.error.message}`);
583+
}
584+
}
585+
513586
if (shouldRefreshLdk) {
514587
const refreshRes = await refreshLdk({ selectedWallet, selectedNetwork });
515588
if (refreshRes.isErr()) {

0 commit comments

Comments
 (0)