Skip to content

Commit 3ecbeb9

Browse files
committed
fix: new getFees
1 parent 59cfcdf commit 3ecbeb9

File tree

8 files changed

+333
-31
lines changed

8 files changed

+333
-31
lines changed

__tests__/lightning.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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+
// Enable fake timers
173+
jest.useFakeTimers();
174+
175+
// Mock slow responses
176+
(global.fetch as jest.Mock) = jest.fn(url => {
177+
if (url === MEMPOOL_URL) {
178+
return new Promise(resolve => {
179+
setTimeout(() => resolve({
180+
ok: true,
181+
json: () => Promise.resolve(mockMempoolResponse),
182+
}), 15000) // longer than timeout
183+
});
184+
}
185+
if (url === BLOCKTANK_URL) {
186+
return new Promise(resolve => {
187+
setTimeout(() => resolve({
188+
ok: true,
189+
json: () => Promise.resolve(mockBlocktankResponse),
190+
}), 15000) // longer than timeout
191+
});
192+
}
193+
return Promise.reject(new Error(`Unexpected URL: ${url}`));
194+
});
195+
196+
// Start the getFees call (don't await yet)
197+
const feesPromise = getFees();
198+
199+
// Fast-forward past the timeout
200+
jest.advanceTimersByTime(11000);
201+
202+
// Now await the promise and expect it to fail
203+
await expect(feesPromise).rejects.toThrow();
204+
expect(fetch).toHaveBeenCalledTimes(2);
205+
206+
// Restore real timers
207+
jest.useRealTimers();
208+
});
209+
});
210+

src/navigation/bottom-sheet/SendNavigation.tsx

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

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

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/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>,

0 commit comments

Comments
 (0)