Skip to content

Commit

Permalink
fix: new getFees
Browse files Browse the repository at this point in the history
  • Loading branch information
limpbrains committed Nov 21, 2024
1 parent 59cfcdf commit a3b07ad
Show file tree
Hide file tree
Showing 8 changed files with 333 additions and 31 deletions.
210 changes: 210 additions & 0 deletions __tests__/lightning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { IBtInfo, IGetFeeEstimatesResponse } from "beignet";

Check warning on line 1 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Strings must use singlequote
import { getFees } from "../src/utils/lightning";

Check warning on line 2 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Strings must use singlequote

jest.mock('../src/utils/wallet', () => ({
getSelectedNetwork: jest.fn(() => 'bitcoin')

Check failure on line 5 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 1 tab but found 4 spaces

Check warning on line 5 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
}));

describe('getFees', () => {
const MEMPOOL_URL = 'https://mempool.space/api/v1/fees/recommended';

Check failure on line 9 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 1 tab but found 4 spaces
const BLOCKTANK_URL = 'https://api1.blocktank.to/api/info';

Check failure on line 10 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 1 tab but found 4 spaces

const mockMempoolResponse: IGetFeeEstimatesResponse = {

Check failure on line 12 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 1 tab but found 4 spaces
fastestFee: 111,

Check failure on line 13 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 2 tabs but found 8 spaces
halfHourFee: 110,

Check failure on line 14 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 2 tabs but found 8 spaces
hourFee: 109,

Check failure on line 15 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 2 tabs but found 8 spaces
minimumFee: 108,

Check failure on line 16 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 2 tabs but found 8 spaces
};

Check failure on line 17 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 1 tab but found 4 spaces

const mockBlocktankResponse: IBtInfo = {

Check failure on line 19 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Expected indentation of 1 tab but found 4 spaces
onchain: {
feeRates: {
fast: 999,
mid: 998,
slow: 997,
}

Check warning on line 25 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
}

Check warning on line 26 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
} 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)

Check warning on line 35 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
});
}
if (url === BLOCKTANK_URL) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockBlocktankResponse)

Check warning on line 41 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
});
}
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)

Check warning on line 73 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
});
}
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

Check warning on line 97 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Trailing spaces not allowed
? Promise.reject('First mempool try failed')
: Promise.resolve({
ok: true,
json: () => Promise.resolve(mockMempoolResponse)

Check warning on line 101 in __tests__/lightning.ts

View workflow job for this annotation

GitHub Actions / Run lint check

Missing trailing comma
});
}
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();
});
});

2 changes: 1 addition & 1 deletion src/navigation/bottom-sheet/SendNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const SendNavigation = (): ReactElement => {

const onOpen = async (): Promise<void> => {
if (!transaction?.lightningInvoice) {
await updateOnchainFeeEstimates({ selectedNetwork, forceUpdate: true });
await updateOnchainFeeEstimates({ forceUpdate: true });
if (!transaction?.inputs.length) {
await setupOnChainTransaction();
}
Expand Down
9 changes: 2 additions & 7 deletions src/screens/Settings/AddressViewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
1 change: 0 additions & 1 deletion src/store/actions/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,6 @@ export const setWalletData = async <K extends keyof IWalletData>(
case 'feeEstimates': {
const feeEstimates = data2 as IWalletData[typeof value];
updateOnchainFeeEstimates({
selectedNetwork: getNetworkFromBeignet(network),
feeEstimates,
forceUpdate: true,
});
Expand Down
19 changes: 4 additions & 15 deletions src/store/utils/fees.ts
Original file line number Diff line number Diff line change
@@ -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<Result<string>> => {
Expand All @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>} promise The promise to resolve.
* @returns {Promise<T>}
*/
export const promiseTimeout = <T>(
ms: number,
promise: Promise<any>,
Expand Down
Loading

0 comments on commit a3b07ad

Please sign in to comment.