From a3b07ad259641c7af66a4edf6e9404764fe8b22f Mon Sep 17 00:00:00 2001 From: Ivan Vershigora Date: Thu, 21 Nov 2024 17:33:47 +0000 Subject: [PATCH] fix: new getFees --- __tests__/lightning.ts | 210 ++++++++++++++++++ .../bottom-sheet/SendNavigation.tsx | 2 +- src/screens/Settings/AddressViewer/index.tsx | 9 +- src/store/actions/wallet.ts | 1 - src/store/utils/fees.ts | 19 +- src/utils/helpers.ts | 6 + src/utils/lightning/index.ts | 108 ++++++++- src/utils/wallet/transactions.ts | 9 +- 8 files changed, 333 insertions(+), 31 deletions(-) create mode 100644 __tests__/lightning.ts diff --git a/__tests__/lightning.ts b/__tests__/lightning.ts new file mode 100644 index 000000000..eabd98461 --- /dev/null +++ b/__tests__/lightning.ts @@ -0,0 +1,210 @@ +import { IBtInfo, IGetFeeEstimatesResponse } from "beignet"; +import { getFees } from "../src/utils/lightning"; + +jest.mock('../src/utils/wallet', () => ({ + getSelectedNetwork: jest.fn(() => 'bitcoin') +})); + +describe('getFees', () => { + const MEMPOOL_URL = 'https://mempool.space/api/v1/fees/recommended'; + const BLOCKTANK_URL = 'https://api1.blocktank.to/api/info'; + + const mockMempoolResponse: IGetFeeEstimatesResponse = { + fastestFee: 111, + halfHourFee: 110, + hourFee: 109, + minimumFee: 108, + }; + + const mockBlocktankResponse: IBtInfo = { + onchain: { + feeRates: { + fast: 999, + mid: 998, + slow: 997, + } + } + } as IBtInfo; + + beforeEach(() => { + jest.clearAllMocks(); + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockMempoolResponse) + }); + } + if (url === BLOCKTANK_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockBlocktankResponse) + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + }); + + it('should use mempool.space when both APIs succeed', async () => { + const result = await getFees(); + + expect(result).toEqual({ + onChainSweep: 111, + maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 111 * 10), + minAllowedAnchorChannelRemoteFee: 108, + minAllowedNonAnchorChannelRemoteFee: 107, + anchorChannelFee: 109, + nonAnchorChannelFee: 110, + channelCloseMinimum: 108, + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith(MEMPOOL_URL); + expect(fetch).toHaveBeenCalledWith(BLOCKTANK_URL); + }); + + it('should use blocktank when mempool.space fails', async () => { + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL) { + return Promise.reject('Mempool failed'); + } + if (url === BLOCKTANK_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockBlocktankResponse) + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const result = await getFees(); + expect(result).toEqual({ + onChainSweep: 999, + maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 999 * 10), + minAllowedAnchorChannelRemoteFee: 997, + minAllowedNonAnchorChannelRemoteFee: 996, + anchorChannelFee: 997, + nonAnchorChannelFee: 998, + channelCloseMinimum: 997, + }); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('should retry mempool once and succeed even if blocktank fails', async () => { + let mempoolAttempts = 0; + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL) { + mempoolAttempts++; + return mempoolAttempts === 1 + ? Promise.reject('First mempool try failed') + : Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockMempoolResponse) + }); + } + if (url === BLOCKTANK_URL) { + return Promise.reject('Blocktank failed'); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const result = await getFees(); + expect(result.onChainSweep).toBe(111); + expect(fetch).toHaveBeenCalledTimes(4); + expect(fetch).toHaveBeenCalledWith(MEMPOOL_URL); + expect(fetch).toHaveBeenCalledWith(BLOCKTANK_URL); + }); + + it('should throw error when all fetches fail', async () => { + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL || url === BLOCKTANK_URL) { + return Promise.reject('API failed'); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + await expect(getFees()).rejects.toThrow(); + expect(fetch).toHaveBeenCalledTimes(4); + }); + + it('should handle invalid mempool response', async () => { + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ fastestFee: 0 }) + }); + } + if (url === BLOCKTANK_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockBlocktankResponse) + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const result = await getFees(); + expect(result.onChainSweep).toBe(999); + }); + + it('should handle invalid blocktank response', async () => { + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockMempoolResponse) + }); + } + if (url === BLOCKTANK_URL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ onchain: { feeRates: { fast: 0 } } }) + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const result = await getFees(); + expect(result.onChainSweep).toBe(111); + }); + + it('should handle timeout errors gracefully', async () => { + // Enable fake timers + jest.useFakeTimers(); + + // Mock slow responses + (global.fetch as jest.Mock) = jest.fn(url => { + if (url === MEMPOOL_URL) { + return new Promise(resolve => + setTimeout(() => resolve({ + ok: true, + json: () => Promise.resolve(mockMempoolResponse) + }), 15000) // longer than timeout + ); + } + if (url === BLOCKTANK_URL) { + return new Promise(resolve => + setTimeout(() => resolve({ + ok: true, + json: () => Promise.resolve(mockBlocktankResponse) + }), 15000) // longer than timeout + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + // Start the getFees call (don't await yet) + const feesPromise = getFees(); + + // Fast-forward past the timeout + jest.advanceTimersByTime(11000); + + // Now await the promise and expect it to fail + await expect(feesPromise).rejects.toThrow(); + expect(fetch).toHaveBeenCalledTimes(2); + + // Restore real timers + jest.useRealTimers(); + }); +}); + diff --git a/src/navigation/bottom-sheet/SendNavigation.tsx b/src/navigation/bottom-sheet/SendNavigation.tsx index 842ccf60f..0842c9cf1 100644 --- a/src/navigation/bottom-sheet/SendNavigation.tsx +++ b/src/navigation/bottom-sheet/SendNavigation.tsx @@ -117,7 +117,7 @@ const SendNavigation = (): ReactElement => { const onOpen = async (): Promise => { if (!transaction?.lightningInvoice) { - await updateOnchainFeeEstimates({ selectedNetwork, forceUpdate: true }); + await updateOnchainFeeEstimates({ forceUpdate: true }); if (!transaction?.inputs.length) { await setupOnChainTransaction(); } diff --git a/src/screens/Settings/AddressViewer/index.tsx b/src/screens/Settings/AddressViewer/index.tsx index dd7b501fa..025370463 100644 --- a/src/screens/Settings/AddressViewer/index.tsx +++ b/src/screens/Settings/AddressViewer/index.tsx @@ -750,13 +750,8 @@ const AddressViewer = ({ // Switching networks requires us to reset LDK. await setupLdk({ selectedWallet, selectedNetwork }); // Start wallet services with the newly selected network. - await startWalletServices({ - selectedNetwork: config.selectedNetwork, - }); - await updateOnchainFeeEstimates({ - selectedNetwork: config.selectedNetwork, - forceUpdate: true, - }); + await startWalletServices({ selectedNetwork: config.selectedNetwork }); + await updateOnchainFeeEstimates({ forceUpdate: true }); updateActivityList(); await syncLedger(); } diff --git a/src/store/actions/wallet.ts b/src/store/actions/wallet.ts index bb1409d58..fd75aaf9a 100644 --- a/src/store/actions/wallet.ts +++ b/src/store/actions/wallet.ts @@ -663,7 +663,6 @@ export const setWalletData = async ( case 'feeEstimates': { const feeEstimates = data2 as IWalletData[typeof value]; updateOnchainFeeEstimates({ - selectedNetwork: getNetworkFromBeignet(network), feeEstimates, forceUpdate: true, }); diff --git a/src/store/utils/fees.ts b/src/store/utils/fees.ts index 8edd89ec4..bba26be12 100644 --- a/src/store/utils/fees.ts +++ b/src/store/utils/fees.ts @@ -1,20 +1,14 @@ -import { ok, err, Result } from '@synonymdev/result'; +import { IOnchainFees } from 'beignet'; +import { Result, err, ok } from '@synonymdev/result'; +import { getOnChainWalletAsync } from '../../utils/wallet'; import { dispatch, getFeesStore } from '../helpers'; import { updateOnchainFees } from '../slices/fees'; -import { getFeeEstimates } from '../../utils/wallet/transactions'; -import { EAvailableNetwork } from '../../utils/networks'; -import { getOnChainWalletAsync, getSelectedNetwork } from '../../utils/wallet'; -import { IOnchainFees } from 'beignet'; - -export const REFRESH_INTERVAL = 60 * 30; // in seconds, 30 minutes export const updateOnchainFeeEstimates = async ({ - selectedNetwork = getSelectedNetwork(), forceUpdate = false, feeEstimates, }: { - selectedNetwork: EAvailableNetwork; forceUpdate?: boolean; feeEstimates?: IOnchainFees; }): Promise> => { @@ -24,12 +18,7 @@ export const updateOnchainFeeEstimates = async ({ } if (!feeEstimates) { - const timestamp = feesStore.onchain.timestamp; - const difference = Math.floor((Date.now() - timestamp) / 1000); - if (!forceUpdate && difference < REFRESH_INTERVAL) { - return ok('On-chain fee estimates are up to date.'); - } - const feeEstimatesRes = await getFeeEstimates(selectedNetwork); + const feeEstimatesRes = await refreshOnchainFeeEstimates({ forceUpdate }); if (feeEstimatesRes.isErr()) { return err(feeEstimatesRes.error); } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 0370856d6..bda673e69 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -5,6 +5,12 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { i18nTime } from '../utils/i18n'; +/** + * Returns the result of a promise, or an error if the promise takes too long to resolve. + * @param {number} ms The time to wait in milliseconds. + * @param {Promise} promise The promise to resolve. + * @returns {Promise} + */ export const promiseTimeout = ( ms: number, promise: Promise, diff --git a/src/utils/lightning/index.ts b/src/utils/lightning/index.ts index ca555516f..1437c8cd9 100644 --- a/src/utils/lightning/index.ts +++ b/src/utils/lightning/index.ts @@ -4,7 +4,13 @@ import * as bitcoin from 'bitcoinjs-lib'; import ecc from '@bitcoinerlab/secp256k1'; import RNFS from 'react-native-fs'; import { err, ok, Result } from '@synonymdev/result'; -import { EPaymentType, TGetAddressHistory } from 'beignet'; +import { + EPaymentType, + IBtInfo, + IGetFeeEstimatesResponse, + IOnchainFees, + TGetAddressHistory, +} from 'beignet'; import lm, { ldk, defaultUserConfig, @@ -103,6 +109,7 @@ import { showToast } from '../notifications'; import i18n from '../i18n'; import { bitkitLedger, syncLedger } from '../ledger'; import { sendNavigation } from '../../navigation/bottom-sheet/SendNavigation'; +import { initialFeesState } from '../../store/slices/fees'; const PAYMENT_TIMEOUT = 8 * 1000; // 8 seconds @@ -233,8 +240,96 @@ const getScriptPubKeyHistory = async ( return await electrum.getScriptPubKeyHistory(scriptPubKey); }; -const getFees: TGetFees = async () => { - const fees = getFeesStore().onchain; +/** + * Fetch fees from mempool.space and blocktank.to, prioritizing mempool.space. + * Multiple attempts are made to fetch the fees from each provider + * Timeout after 10 seconds + */ +export const getFees: TGetFees = async () => { + const throwTimeout = (t: number): Promise => { + return new Promise((_, rej) => { + setTimeout(() => rej(new Error('timeout')), t); + }); + }; + + const fetchMp = async (): Promise => { + const f1 = await fetch('https://mempool.space/api/v1/fees/recommended'); + const j: IGetFeeEstimatesResponse = await f1.json(); + if ( + !f1.ok || + !( + j.fastestFee > 0 && + j.halfHourFee > 0 && + j.hourFee > 0 && + j.minimumFee > 0 + ) + ) { + throw new Error('Failed to fetch mempool.space fees'); + } + return { + fast: j.fastestFee, + normal: j.halfHourFee, + slow: j.hourFee, + minimum: j.minimumFee, + timestamp: Date.now(), + }; + }; + + const fetchBt = async (): Promise => { + const f2 = await fetch('https://api1.blocktank.to/api/info'); + const j: IBtInfo = await f2.json(); + if ( + !f2.ok || + !( + j?.onchain?.feeRates?.fast > 0 && + j?.onchain?.feeRates?.mid > 0 && + j?.onchain?.feeRates?.slow > 0 + ) + ) { + throw new Error('Failed to fetch blocktank fees'); + } + const { fast, mid, slow } = j.onchain.feeRates; + return { + fast, + normal: mid, + slow, + minimum: slow, + timestamp: Date.now(), + }; + }; + + let fees: IOnchainFees; + if (getFeesStore().override) { + fees = getFeesStore().onchain; + } else if (getSelectedNetwork() !== 'bitcoin') { + fees = initialFeesState.onchain; + } else { + fees = await new Promise((resolve, reject) => { + // try twice + const mpPromise = Promise.race([ + fetchMp().catch(fetchMp), + throwTimeout(10000), + ]); + const btPromise = Promise.race([ + fetchBt().catch(fetchBt), + throwTimeout(10000), + ]).catch(() => null); // Prevent unhandled rejection + + // prioritize mempool.space over blocktank + mpPromise.then(resolve).catch(() => { + btPromise + .then((btFees) => { + if (btFees !== null) { + resolve(btFees); + } else { + reject(new Error('Failed to fetch fees')); + } + }) + .catch(reject); + }); + }); + } + return { //https://github.com/lightningdevkit/rust-lightning/blob/main/CHANGELOG.md#api-updates onChainSweep: fees.fast, @@ -335,6 +430,13 @@ export const setupLdk = async ({ return err(backupRes.error); } + // check if getFees is working + try { + await getFees(); + } catch (e) { + return err(e); + } + const lmStart = await lm.start({ account: account.value, getFees, diff --git a/src/utils/wallet/transactions.ts b/src/utils/wallet/transactions.ts index dfa0d1322..e99abc489 100644 --- a/src/utils/wallet/transactions.ts +++ b/src/utils/wallet/transactions.ts @@ -887,6 +887,7 @@ export const broadcastBoost = async ({ */ export const getFeeEstimates = async ( selectedNetwork: EAvailableNetwork = getSelectedNetwork(), + forceUpdate: boolean, ): Promise> => { try { if (__E2E__) { @@ -904,11 +905,11 @@ export const getFeeEstimates = async ( } const wallet = await getOnChainWalletAsync(); - const feeRes = await wallet.getFeeEstimates(); - if (!feeRes) { - return err('Unable to get fee estimates.'); + const feeRes = await wallet.updateFeeEstimates(forceUpdate); + if (feeRes.isErr()) { + return err(feeRes.error); } - return ok(feeRes); + return ok(feeRes.value); } catch (e) { return err(e); }