From 5fa328f1b369c7542b37a1f0234ac3ac4a495809 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 11 Nov 2025 14:03:10 -0300 Subject: [PATCH 1/6] chore: update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From d841f52675f147e7681076f2fe8c2502c0dbe87e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 11 Nov 2025 14:04:58 -0300 Subject: [PATCH 2/6] fix: remove fee persistense --- src/store/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/index.ts b/src/store/index.ts index f129ee7b6..951922e11 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -34,7 +34,7 @@ const persistConfig = { // increase version after store shape changes version: 54, stateReconciler: autoMergeLevel2, - blacklist: ['receive', 'ui'], + blacklist: ['receive', 'ui', 'fees'], migrate: createMigrate(migrations, { debug: __ENABLE_MIGRATION_DEBUG__ }), }; From 63ae06ad87e9d2327bd97ba3021964a0099096e1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 06:51:54 -0300 Subject: [PATCH 3/6] fix: add fallback logic for fee caching --- src/store/index.ts | 2 +- src/utils/lightning/index.ts | 67 +++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 951922e11..f129ee7b6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -34,7 +34,7 @@ const persistConfig = { // increase version after store shape changes version: 54, stateReconciler: autoMergeLevel2, - blacklist: ['receive', 'ui', 'fees'], + blacklist: ['receive', 'ui'], migrate: createMigrate(migrations, { debug: __ENABLE_MIGRATION_DEBUG__ }), }; diff --git a/src/utils/lightning/index.ts b/src/utils/lightning/index.ts index 9f5d2d32d..11159c99d 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 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); + }); }); - }); - 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 { From c0d8351279175b2ebe47b0bf291e01c807223caf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 07:12:23 -0300 Subject: [PATCH 4/6] fix: prioritize blocktank over mempool.space --- src/utils/lightning/index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/utils/lightning/index.ts b/src/utils/lightning/index.ts index 11159c99d..c7400b9f7 100644 --- a/src/utils/lightning/index.ts +++ b/src/utils/lightning/index.ts @@ -321,21 +321,21 @@ export const getFees: TGetFees = async () => { try { 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), + ]); + const mpPromise = Promise.race([ + fetchMp().catch(fetchMp), + throwTimeout(10000), ]).catch(() => null); // Prevent unhandled rejection - // prioritize mempool.space over blocktank - mpPromise.then(resolve).catch(() => { - btPromise - .then((btFees) => { - if (btFees !== null) { - resolve(btFees); + // 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')); } From d91d72d734c96a88afddae2ad22898c7541c529c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 07:43:49 -0300 Subject: [PATCH 5/6] chore: lint --- src/utils/lightning/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/lightning/index.ts b/src/utils/lightning/index.ts index c7400b9f7..5648ff396 100644 --- a/src/utils/lightning/index.ts +++ b/src/utils/lightning/index.ts @@ -345,7 +345,7 @@ export const getFees: TGetFees = async () => { }); updateOnchainFeeEstimates({ feeEstimates: fees }); - } catch (error) { + } catch (_error) { // FALLBACK: Use cached fees if network fetch fails const cachedFees = getFeesStore().onchain; const feeAge = Date.now() - cachedFees.timestamp; From 15f3466650eaeb8f8085eab3612693199be59d96 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 08:20:23 -0300 Subject: [PATCH 6/6] test: update tests --- __tests__/lightning.test.ts | 123 ++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 48 deletions(-) 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();