Skip to content

Commit

Permalink
fix: do not start LDK unless we have fresh fee and block data
Browse files Browse the repository at this point in the history
  • Loading branch information
limpbrains committed Nov 26, 2024
1 parent 0fbc9c3 commit 021d686
Show file tree
Hide file tree
Showing 14 changed files with 406 additions and 66 deletions.
204 changes: 204 additions & 0 deletions __tests__/lightning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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 () => {
jest.useFakeTimers();

(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}`));
});

const feesPromise = getFees();

jest.advanceTimersByTime(11000);

await expect(feesPromise).rejects.toThrow();
expect(fetch).toHaveBeenCalledTimes(2);

jest.useRealTimers();
});
});

16 changes: 8 additions & 8 deletions e2e/send.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ d('Send', () => {
await waitFor(
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
)
.toHaveText('109 170')
.toHaveText('109 004')
.withTimeout(10000);

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

// send to unified invoice w/o amount (lightning)
Expand All @@ -377,7 +377,7 @@ d('Send', () => {
await expect(element(by.text('28 900'))).toBeVisible();
await element(by.id('AssetButton-switch')).tap();
// max amount (onchain)
await expect(element(by.text('68 506'))).toBeVisible();
await expect(element(by.text('68 008'))).toBeVisible();
await element(by.id('AssetButton-switch')).tap();
await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap();
await element(
Expand All @@ -392,7 +392,7 @@ d('Send', () => {
await waitFor(
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
)
.toHaveText('88 838')
.toHaveText('88 506')
.withTimeout(10000);

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

await element(
Expand All @@ -430,7 +430,7 @@ d('Send', () => {
await waitFor(
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
)
.toHaveText('78 506')
.toHaveText('78 008')
.withTimeout(10000);

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

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

// send to lightning invoice w/ amount (skip quickpay for large amounts)
Expand Down
2 changes: 1 addition & 1 deletion src/navigation/bottom-sheet/SendNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,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
9 changes: 8 additions & 1 deletion src/screens/Settings/DevSettings/LdkDebug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@ const LdkDebug = (): ReactElement => {

const onRestartLdk = async (): Promise<void> => {
setRestartingLdk(true);
await setupLdk({ selectedWallet, selectedNetwork });
const res = await setupLdk({ selectedWallet, selectedNetwork });
if (res.isErr()) {
showToast({
type: 'error',
title: t('wallet:ldk_start_error_title'),
description: res.error.message,
});
}
setRestartingLdk(false);
};

Expand Down
20 changes: 14 additions & 6 deletions src/screens/Settings/RGSServer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,23 @@ const RGSServer = ({
const connectToRGSServer = async (): Promise<void> => {
setLoading(true);
dispatch(updateSettings({ rapidGossipSyncUrl: rgsUrl }));
await setupLdk({
const res = await setupLdk({
selectedWallet,
selectedNetwork,
});
showToast({
type: 'success',
title: t('rgs.update_success_title'),
description: t('rgs.update_success_description'),
});
if (res.isOk()) {
showToast({
type: 'success',
title: t('rgs.update_success_title'),
description: t('rgs.update_success_description'),
});
} else {
showToast({
type: 'error',
title: t('wallet:ldk_start_error_title'),
description: res.error.message,
});
}
setLoading(false);
};

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
Loading

0 comments on commit 021d686

Please sign in to comment.