Skip to content

Commit 1f1658e

Browse files
committed
fix: do not start LDK unless we have fresh fee and block data
1 parent 0fbc9c3 commit 1f1658e

File tree

12 files changed

+389
-58
lines changed

12 files changed

+389
-58
lines changed

__tests__/lightning.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { IBtInfo, IGetFeeEstimatesResponse } from 'beignet';
2+
import { getFees } from '../src/utils/lightning';
3+
4+
jest.mock('../src/utils/wallet', () => ({
5+
getSelectedNetwork: jest.fn(() => 'bitcoin'),
6+
}));
7+
8+
describe('getFees', () => {
9+
const MEMPOOL_URL = 'https://mempool.space/api/v1/fees/recommended';
10+
const BLOCKTANK_URL = 'https://api1.blocktank.to/api/info';
11+
12+
const mockMempoolResponse: IGetFeeEstimatesResponse = {
13+
fastestFee: 111,
14+
halfHourFee: 110,
15+
hourFee: 109,
16+
minimumFee: 108,
17+
};
18+
19+
const mockBlocktankResponse: IBtInfo = {
20+
onchain: {
21+
feeRates: {
22+
fast: 999,
23+
mid: 998,
24+
slow: 997,
25+
},
26+
},
27+
} as IBtInfo;
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
(global.fetch as jest.Mock) = jest.fn(url => {
32+
if (url === MEMPOOL_URL) {
33+
return Promise.resolve({
34+
ok: true,
35+
json: () => Promise.resolve(mockMempoolResponse),
36+
});
37+
}
38+
if (url === BLOCKTANK_URL) {
39+
return Promise.resolve({
40+
ok: true,
41+
json: () => Promise.resolve(mockBlocktankResponse),
42+
});
43+
}
44+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
45+
});
46+
});
47+
48+
it('should use mempool.space when both APIs succeed', async () => {
49+
const result = await getFees();
50+
51+
expect(result).toEqual({
52+
onChainSweep: 111,
53+
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 111 * 10),
54+
minAllowedAnchorChannelRemoteFee: 108,
55+
minAllowedNonAnchorChannelRemoteFee: 107,
56+
anchorChannelFee: 109,
57+
nonAnchorChannelFee: 110,
58+
channelCloseMinimum: 108,
59+
});
60+
expect(fetch).toHaveBeenCalledTimes(2);
61+
expect(fetch).toHaveBeenCalledWith(MEMPOOL_URL);
62+
expect(fetch).toHaveBeenCalledWith(BLOCKTANK_URL);
63+
});
64+
65+
it('should use blocktank when mempool.space fails', async () => {
66+
(global.fetch as jest.Mock) = jest.fn(url => {
67+
if (url === MEMPOOL_URL) {
68+
return Promise.reject('Mempool failed');
69+
}
70+
if (url === BLOCKTANK_URL) {
71+
return Promise.resolve({
72+
ok: true,
73+
json: () => Promise.resolve(mockBlocktankResponse),
74+
});
75+
}
76+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
77+
});
78+
79+
const result = await getFees();
80+
expect(result).toEqual({
81+
onChainSweep: 999,
82+
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 999 * 10),
83+
minAllowedAnchorChannelRemoteFee: 997,
84+
minAllowedNonAnchorChannelRemoteFee: 996,
85+
anchorChannelFee: 997,
86+
nonAnchorChannelFee: 998,
87+
channelCloseMinimum: 997,
88+
});
89+
expect(fetch).toHaveBeenCalledTimes(3);
90+
});
91+
92+
it('should retry mempool once and succeed even if blocktank fails', async () => {
93+
let mempoolAttempts = 0;
94+
(global.fetch as jest.Mock) = jest.fn(url => {
95+
if (url === MEMPOOL_URL) {
96+
mempoolAttempts++;
97+
return mempoolAttempts === 1
98+
? Promise.reject('First mempool try failed')
99+
: Promise.resolve({
100+
ok: true,
101+
json: () => Promise.resolve(mockMempoolResponse),
102+
});
103+
}
104+
if (url === BLOCKTANK_URL) {
105+
return Promise.reject('Blocktank failed');
106+
}
107+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
108+
});
109+
110+
const result = await getFees();
111+
expect(result.onChainSweep).toBe(111);
112+
expect(fetch).toHaveBeenCalledTimes(4);
113+
expect(fetch).toHaveBeenCalledWith(MEMPOOL_URL);
114+
expect(fetch).toHaveBeenCalledWith(BLOCKTANK_URL);
115+
});
116+
117+
it('should throw error when all fetches fail', async () => {
118+
(global.fetch as jest.Mock) = jest.fn(url => {
119+
if (url === MEMPOOL_URL || url === BLOCKTANK_URL) {
120+
return Promise.reject('API failed');
121+
}
122+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
123+
});
124+
125+
await expect(getFees()).rejects.toThrow();
126+
expect(fetch).toHaveBeenCalledTimes(4);
127+
});
128+
129+
it('should handle invalid mempool response', async () => {
130+
(global.fetch as jest.Mock) = jest.fn(url => {
131+
if (url === MEMPOOL_URL) {
132+
return Promise.resolve({
133+
ok: true,
134+
json: () => Promise.resolve({ fastestFee: 0 }),
135+
});
136+
}
137+
if (url === BLOCKTANK_URL) {
138+
return Promise.resolve({
139+
ok: true,
140+
json: () => Promise.resolve(mockBlocktankResponse),
141+
});
142+
}
143+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
144+
});
145+
146+
const result = await getFees();
147+
expect(result.onChainSweep).toBe(999);
148+
});
149+
150+
it('should handle invalid blocktank response', async () => {
151+
(global.fetch as jest.Mock) = jest.fn(url => {
152+
if (url === MEMPOOL_URL) {
153+
return Promise.resolve({
154+
ok: true,
155+
json: () => Promise.resolve(mockMempoolResponse),
156+
});
157+
}
158+
if (url === BLOCKTANK_URL) {
159+
return Promise.resolve({
160+
ok: true,
161+
json: () => Promise.resolve({ onchain: { feeRates: { fast: 0 } } }),
162+
});
163+
}
164+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
165+
});
166+
167+
const result = await getFees();
168+
expect(result.onChainSweep).toBe(111);
169+
});
170+
171+
it('should handle timeout errors gracefully', async () => {
172+
jest.useFakeTimers();
173+
174+
(global.fetch as jest.Mock) = jest.fn(url => {
175+
if (url === MEMPOOL_URL) {
176+
return new Promise(resolve => {
177+
setTimeout(() => resolve({
178+
ok: true,
179+
json: () => Promise.resolve(mockMempoolResponse),
180+
}), 15000); // longer than timeout
181+
});
182+
}
183+
if (url === BLOCKTANK_URL) {
184+
return new Promise(resolve => {
185+
setTimeout(() => resolve({
186+
ok: true,
187+
json: () => Promise.resolve(mockBlocktankResponse),
188+
}), 15000); // longer than timeout
189+
});
190+
}
191+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
192+
});
193+
194+
const feesPromise = getFees();
195+
196+
jest.advanceTimersByTime(11000);
197+
198+
await expect(feesPromise).rejects.toThrow();
199+
expect(fetch).toHaveBeenCalledTimes(2);
200+
201+
jest.useRealTimers();
202+
});
203+
});
204+

src/navigation/bottom-sheet/SendNavigation.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ const SendNavigation = (): ReactElement => {
121121

122122
const onOpen = async (): Promise<void> => {
123123
if (!transaction?.lightningInvoice) {
124-
await updateOnchainFeeEstimates({ selectedNetwork, forceUpdate: true });
124+
await updateOnchainFeeEstimates({ forceUpdate: true });
125125
if (!transaction?.inputs.length) {
126126
await setupOnChainTransaction();
127127
}

src/screens/Settings/AddressViewer/index.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -750,13 +750,8 @@ const AddressViewer = ({
750750
// Switching networks requires us to reset LDK.
751751
await setupLdk({ selectedWallet, selectedNetwork });
752752
// Start wallet services with the newly selected network.
753-
await startWalletServices({
754-
selectedNetwork: config.selectedNetwork,
755-
});
756-
await updateOnchainFeeEstimates({
757-
selectedNetwork: config.selectedNetwork,
758-
forceUpdate: true,
759-
});
753+
await startWalletServices({ selectedNetwork: config.selectedNetwork });
754+
await updateOnchainFeeEstimates({ forceUpdate: true });
760755
updateActivityList();
761756
await syncLedger();
762757
}

src/screens/Settings/DevSettings/LdkDebug.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ const LdkDebug = (): ReactElement => {
7676

7777
const onRestartLdk = async (): Promise<void> => {
7878
setRestartingLdk(true);
79-
await setupLdk({ selectedWallet, selectedNetwork });
79+
const res = await setupLdk({ selectedWallet, selectedNetwork });
80+
if (res.isErr()) {
81+
showToast({
82+
type: 'error',
83+
title: t('wallet:ldk_start_error_title'),
84+
description: res.error.message,
85+
});
86+
}
8087
setRestartingLdk(false);
8188
};
8289

src/screens/Settings/RGSServer/index.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,23 @@ const RGSServer = ({
7474
const connectToRGSServer = async (): Promise<void> => {
7575
setLoading(true);
7676
dispatch(updateSettings({ rapidGossipSyncUrl: rgsUrl }));
77-
await setupLdk({
77+
const res = await setupLdk({
7878
selectedWallet,
7979
selectedNetwork,
8080
});
81-
showToast({
82-
type: 'success',
83-
title: t('rgs.update_success_title'),
84-
description: t('rgs.update_success_description'),
85-
});
81+
if (res.isOk()) {
82+
showToast({
83+
type: 'success',
84+
title: t('rgs.update_success_title'),
85+
description: t('rgs.update_success_description'),
86+
});
87+
} else {
88+
showToast({
89+
type: 'error',
90+
title: t('wallet:ldk_start_error_title'),
91+
description: res.error.message,
92+
});
93+
}
8694
setLoading(false);
8795
};
8896

src/store/actions/wallet.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,6 @@ export const setWalletData = async <K extends keyof IWalletData>(
663663
case 'feeEstimates': {
664664
const feeEstimates = data2 as IWalletData[typeof value];
665665
updateOnchainFeeEstimates({
666-
selectedNetwork: getNetworkFromBeignet(network),
667666
feeEstimates,
668667
forceUpdate: true,
669668
});

src/store/utils/fees.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
import { ok, err, Result } from '@synonymdev/result';
1+
import { IOnchainFees } from 'beignet';
2+
import { Result, err, ok } from '@synonymdev/result';
23

4+
import { getOnChainWalletAsync } from '../../utils/wallet';
35
import { dispatch, getFeesStore } from '../helpers';
46
import { updateOnchainFees } from '../slices/fees';
5-
import { getFeeEstimates } from '../../utils/wallet/transactions';
6-
import { EAvailableNetwork } from '../../utils/networks';
7-
import { getOnChainWalletAsync, getSelectedNetwork } from '../../utils/wallet';
8-
import { IOnchainFees } from 'beignet';
9-
10-
export const REFRESH_INTERVAL = 60 * 30; // in seconds, 30 minutes
117

128
export const updateOnchainFeeEstimates = async ({
13-
selectedNetwork = getSelectedNetwork(),
149
forceUpdate = false,
1510
feeEstimates,
1611
}: {
17-
selectedNetwork: EAvailableNetwork;
1812
forceUpdate?: boolean;
1913
feeEstimates?: IOnchainFees;
2014
}): Promise<Result<string>> => {
@@ -24,12 +18,7 @@ export const updateOnchainFeeEstimates = async ({
2418
}
2519

2620
if (!feeEstimates) {
27-
const timestamp = feesStore.onchain.timestamp;
28-
const difference = Math.floor((Date.now() - timestamp) / 1000);
29-
if (!forceUpdate && difference < REFRESH_INTERVAL) {
30-
return ok('On-chain fee estimates are up to date.');
31-
}
32-
const feeEstimatesRes = await getFeeEstimates(selectedNetwork);
21+
const feeEstimatesRes = await refreshOnchainFeeEstimates({ forceUpdate });
3322
if (feeEstimatesRes.isErr()) {
3423
return err(feeEstimatesRes.error);
3524
}

src/utils/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
55

66
import { i18nTime } from '../utils/i18n';
77

8+
/**
9+
* Returns the result of a promise, or an error if the promise takes too long to resolve.
10+
* @param {number} ms The time to wait in milliseconds.
11+
* @param {Promise<any>} promise The promise to resolve.
12+
* @returns {Promise<T>}
13+
*/
814
export const promiseTimeout = <T>(
915
ms: number,
1016
promise: Promise<any>,

src/utils/i18n/locales/en/wallet.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,9 @@
689689
"ldk_sync_error_title": {
690690
"string": "Lightning Sync Error"
691691
},
692+
"ldk_start_error_title": {
693+
"string": "Lightning Startup Error"
694+
},
692695
"receive_insufficient_title": {
693696
"string": "Insufficient receiving balance."
694697
},

0 commit comments

Comments
 (0)