Skip to content

Commit 021d686

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

File tree

14 files changed

+406
-66
lines changed

14 files changed

+406
-66
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+

e2e/send.e2e.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ d('Send', () => {
343343
await waitFor(
344344
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
345345
)
346-
.toHaveText('109 170')
346+
.toHaveText('109 004')
347347
.withTimeout(10000);
348348

349349
// send to unified invoice w/ expired invoice
@@ -365,7 +365,7 @@ d('Send', () => {
365365
await waitFor(
366366
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
367367
)
368-
.toHaveText('98 838')
368+
.toHaveText('98 506')
369369
.withTimeout(10000);
370370

371371
// send to unified invoice w/o amount (lightning)
@@ -377,7 +377,7 @@ d('Send', () => {
377377
await expect(element(by.text('28 900'))).toBeVisible();
378378
await element(by.id('AssetButton-switch')).tap();
379379
// max amount (onchain)
380-
await expect(element(by.text('68 506'))).toBeVisible();
380+
await expect(element(by.text('68 008'))).toBeVisible();
381381
await element(by.id('AssetButton-switch')).tap();
382382
await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap();
383383
await element(
@@ -392,7 +392,7 @@ d('Send', () => {
392392
await waitFor(
393393
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
394394
)
395-
.toHaveText('88 838')
395+
.toHaveText('88 506')
396396
.withTimeout(10000);
397397

398398
// send to unified invoice w/o amount (switch to onchain)
@@ -411,7 +411,7 @@ d('Send', () => {
411411
await element(by.id('AssetButton-switch')).tap();
412412
await element(by.id('AvailableAmount')).tap();
413413
await element(by.id('ContinueAmount')).tap();
414-
await expect(element(by.text('68 506'))).toBeVisible();
414+
await expect(element(by.text('68 008'))).toBeVisible();
415415
await element(by.id('NavigationBack')).atIndex(0).tap();
416416

417417
await element(
@@ -430,7 +430,7 @@ d('Send', () => {
430430
await waitFor(
431431
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
432432
)
433-
.toHaveText('78 506')
433+
.toHaveText('78 008')
434434
.withTimeout(10000);
435435

436436
// send to lightning invoice w/ amount (quickpay)
@@ -452,7 +452,7 @@ d('Send', () => {
452452
await waitFor(
453453
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
454454
)
455-
.toHaveText('77 506')
455+
.toHaveText('77 008')
456456
.withTimeout(10000);
457457

458458
// send to unified invoice w/ amount (quickpay)
@@ -470,7 +470,7 @@ d('Send', () => {
470470
await waitFor(
471471
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
472472
)
473-
.toHaveText('76 506')
473+
.toHaveText('76 008')
474474
.withTimeout(10000);
475475

476476
// send to lightning invoice w/ amount (skip quickpay for large amounts)

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
}

0 commit comments

Comments
 (0)