Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,6 @@ android/app/release/

# Optimized SVGs
src/assets/svgs/optimized/

#AI
CLAUDE.md
123 changes: 75 additions & 48 deletions __tests__/lightning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ jest.mock('../src/utils/wallet', () => ({
getSelectedNetwork: jest.fn(() => 'bitcoin'),
}));

jest.mock('../src/store/helpers', () => ({
getFeesStore: jest.fn(() => ({
onchain: {
fast: 50,
normal: 40,
slow: 30,
minimum: 20,
timestamp: Date.now() - 30 * 60 * 1000, // 30 minutes ago
},
override: false,
})),
}));

describe('getFees', () => {
const MEMPOOL_URL = 'https://mempool.space/api/v1/fees/recommended';
const BLOCKTANK_URL = 'https://api1.blocktank.to/api/info';
Expand Down Expand Up @@ -45,134 +58,146 @@ describe('getFees', () => {
});
});

it('should use mempool.space when both APIs succeed', async () => {
it('should use blocktank when both APIs succeed', async () => {
const result = await getFees();

expect(result).toEqual({
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 111 * 10),
minAllowedAnchorChannelRemoteFee: 108,
minAllowedNonAnchorChannelRemoteFee: 107,
anchorChannelFee: 109,
nonAnchorChannelFee: 110,
channelCloseMinimum: 108,
outputSpendingFee: 111,
maximumFeeEstimate: 111 * 10,
urgentOnChainSweep: 111,
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 999 * 10),
minAllowedAnchorChannelRemoteFee: 997,
minAllowedNonAnchorChannelRemoteFee: 996,
anchorChannelFee: 997,
nonAnchorChannelFee: 998,
channelCloseMinimum: 997,
outputSpendingFee: 999,
maximumFeeEstimate: 999 * 10,
urgentOnChainSweep: 999,
});
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(MEMPOOL_URL);
expect(fetch).toHaveBeenCalledWith(BLOCKTANK_URL);
});

it('should use blocktank when mempool.space fails', async () => {
it('should use mempool.space when blocktank 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.reject('Blocktank failed');
}
if (url === MEMPOOL_URL) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockBlocktankResponse),
json: () => Promise.resolve(mockMempoolResponse),
});
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});

const result = await getFees();
expect(result).toEqual({
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 999 * 10),
minAllowedAnchorChannelRemoteFee: 997,
minAllowedNonAnchorChannelRemoteFee: 996,
anchorChannelFee: 997,
nonAnchorChannelFee: 998,
channelCloseMinimum: 997,
outputSpendingFee: 999,
maximumFeeEstimate: 999 * 10,
urgentOnChainSweep: 999,
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 111 * 10),
minAllowedAnchorChannelRemoteFee: 108,
minAllowedNonAnchorChannelRemoteFee: 107,
anchorChannelFee: 109,
nonAnchorChannelFee: 110,
channelCloseMinimum: 108,
outputSpendingFee: 111,
maximumFeeEstimate: 111 * 10,
urgentOnChainSweep: 111,
});
expect(fetch).toHaveBeenCalledTimes(3);
});

it('should retry mempool once and succeed even if blocktank fails', async () => {
let mempoolAttempts = 0;
it('should retry blocktank once and succeed even if mempool fails', async () => {
let blocktankAttempts = 0;
(global.fetch as jest.Mock) = jest.fn(url => {
if (url === MEMPOOL_URL) {
mempoolAttempts++;
return mempoolAttempts === 1
? Promise.reject('First mempool try failed')
if (url === BLOCKTANK_URL) {
blocktankAttempts++;
return blocktankAttempts === 1
? Promise.reject('First blocktank try failed')
: Promise.resolve({
ok: true,
json: () => Promise.resolve(mockMempoolResponse),
json: () => Promise.resolve(mockBlocktankResponse),
});
}
if (url === BLOCKTANK_URL) {
return Promise.reject('Blocktank failed');
if (url === MEMPOOL_URL) {
return Promise.reject('Mempool failed');
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});

const result = await getFees();
expect(result.urgentOnChainSweep).toBe(111);
expect(result.urgentOnChainSweep).toBe(999);
expect(fetch).toHaveBeenCalledTimes(4);
expect(fetch).toHaveBeenCalledWith(MEMPOOL_URL);
expect(fetch).toHaveBeenCalledWith(BLOCKTANK_URL);
});

it('should throw error when all fetches fail', async () => {
it('should use cached fees 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();
const result = await getFees();
// Should use cached fees from mock (30 minutes old)
expect(result).toEqual({
maxAllowedNonAnchorChannelRemoteFee: Math.max(25, 50 * 10),
minAllowedAnchorChannelRemoteFee: 20,
minAllowedNonAnchorChannelRemoteFee: 19,
anchorChannelFee: 30,
nonAnchorChannelFee: 40,
channelCloseMinimum: 20,
outputSpendingFee: 50,
maximumFeeEstimate: 50 * 10,
urgentOnChainSweep: 50,
});
expect(fetch).toHaveBeenCalledTimes(4);
});

it('should handle invalid mempool response', async () => {
it('should handle invalid blocktank response and use mempool', async () => {
(global.fetch as jest.Mock) = jest.fn(url => {
if (url === MEMPOOL_URL) {
if (url === BLOCKTANK_URL) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ fastestFee: 0 }),
json: () => Promise.resolve({ onchain: { feeRates: { fast: 0 } } }),
});
}
if (url === BLOCKTANK_URL) {
if (url === MEMPOOL_URL) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockBlocktankResponse),
json: () => Promise.resolve(mockMempoolResponse),
});
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});

const result = await getFees();
expect(result.urgentOnChainSweep).toBe(999);
expect(result.urgentOnChainSweep).toBe(111);
});

it('should handle invalid blocktank response', async () => {
it('should handle invalid mempool response and use blocktank', async () => {
(global.fetch as jest.Mock) = jest.fn(url => {
if (url === MEMPOOL_URL) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockMempoolResponse),
json: () => Promise.resolve({ fastestFee: 0 }),
});
}
if (url === BLOCKTANK_URL) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ onchain: { feeRates: { fast: 0 } } }),
json: () => Promise.resolve(mockBlocktankResponse),
});
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});

const result = await getFees();
expect(result.urgentOnChainSweep).toBe(111);
expect(result.urgentOnChainSweep).toBe(999);
});

it('should handle timeout errors gracefully', async () => {
it('should use cached fees when all requests timeout', async () => {
jest.useFakeTimers();

(global.fetch as jest.Mock) = jest.fn(url => {
Expand All @@ -199,7 +224,9 @@ describe('getFees', () => {

jest.advanceTimersByTime(11000);

await expect(feesPromise).rejects.toThrow();
const result = await feesPromise;
// Should use cached fees when all requests timeout
expect(result.urgentOnChainSweep).toBe(50);
expect(fetch).toHaveBeenCalledTimes(2);

jest.useRealTimers();
Expand Down
67 changes: 43 additions & 24 deletions src/utils/lightning/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,32 +318,51 @@ export const getFees: TGetFees = async () => {
} else if (getSelectedNetwork() !== 'bitcoin') {
fees = initialFeesState.onchain;
} else {
fees = await new Promise<IOnchainFees>((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);
try {
fees = await new Promise<IOnchainFees>((resolve, reject) => {
// try twice
const btPromise = Promise.race([
fetchBt().catch(fetchBt),
throwTimeout(10000),
]);
const mpPromise = Promise.race([
fetchMp().catch(fetchMp),
throwTimeout(10000),
]).catch(() => null); // Prevent unhandled rejection

// prioritize blocktank over mempool.space
btPromise.then(resolve).catch(() => {
mpPromise
.then((mpFees) => {
if (mpFees !== null) {
resolve(mpFees);
} else {
reject(new Error('Failed to fetch fees'));
}
})
.catch(reject);
});
});
});

updateOnchainFeeEstimates({ feeEstimates: fees });
updateOnchainFeeEstimates({ feeEstimates: fees });
} catch (_error) {
// FALLBACK: Use cached fees if network fetch fails
const cachedFees = getFeesStore().onchain;
const feeAge = Date.now() - cachedFees.timestamp;
const MAX_FEE_AGE = 60 * 60 * 1000; // 1 hour

if (feeAge < MAX_FEE_AGE) {
console.warn(
`getFees: Network fetch failed, using cached fees (age: ${Math.round(feeAge / 1000 / 60)} minutes)`,
);
fees = cachedFees;
} else {
console.warn(
`getFees: Network fetch failed and cached fees too old (age: ${Math.round(feeAge / 1000 / 60)} minutes), using initial state`,
);
fees = initialFeesState.onchain;
}
}
}

return {
Expand Down
Loading