diff --git a/.gitignore b/.gitignore index 4148d94c2..04483faeb 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,6 @@ android/app/release/ # Optimized SVGs src/assets/svgs/optimized/ + +#AI +CLAUDE.md diff --git a/__tests__/lightning.test.ts b/__tests__/lightning.test.ts index 8ba4ae806..576cc826a 100644 --- a/__tests__/lightning.test.ts +++ b/__tests__/lightning.test.ts @@ -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'; @@ -45,34 +58,34 @@ 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}`)); @@ -80,45 +93,45 @@ describe('getFees', () => { 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'); @@ -126,53 +139,65 @@ describe('getFees', () => { 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 => { @@ -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(); diff --git a/src/utils/lightning/index.ts b/src/utils/lightning/index.ts index 9f5d2d32d..5648ff396 100644 --- a/src/utils/lightning/index.ts +++ b/src/utils/lightning/index.ts @@ -318,32 +318,51 @@ export const getFees: TGetFees = async () => { } 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); + try { + fees = await new Promise((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 {