From 8676db8e16f656e1daa536632cde7619ab040c35 Mon Sep 17 00:00:00 2001 From: Blake Kaufman <68204898+BlakeKaufman@users.noreply.github.com> Date: Mon, 18 May 2026 11:16:12 -0400 Subject: [PATCH] Remove txs from memory (#849) * memorizing context values * reduce stored txs in memory * improve db lookup * dismiss keyboard before going back --- .../spark/transactionsBulkUpdate.test.js | 107 ++++++++++++ .../onlineListingsFilterHalfModal.js | 25 ++- .../utilityFunctions.js | 15 +- .../admin/homeComponents/homeLightning.js | 18 ++- .../homeLightning/sendReciveBTNs.js | 6 +- .../homeLightning/sparkErrorScreen.js | 3 +- .../sendBitcoin/functions/hasPaid.js | 13 +- .../settingsContent/displayOptions.js | 9 +- app/functions/customNavigation.js | 4 +- app/functions/detectQrCode.js | 14 +- app/functions/initiateWalletConnection.js | 23 +-- app/functions/lrc20/index.js | 58 +++---- app/functions/spark/filterTransactions.js | 98 +++++++++++ app/functions/spark/restore.js | 1 - app/functions/spark/transactions.js | 95 +++++++++++ app/hooks/useAccountSwitcher.js | 4 +- app/screens/inAccount/viewAllTxPage.js | 5 +- context-store/activeAccount.js | 65 +++++--- context-store/analyticsContext.js | 64 +++++--- context-store/sparkContext.js | 153 ++++++++++++++---- context-store/webViewContext.js | 27 ++-- 21 files changed, 633 insertions(+), 174 deletions(-) create mode 100644 app/functions/spark/filterTransactions.js diff --git a/__tests__/functions/spark/transactionsBulkUpdate.test.js b/__tests__/functions/spark/transactionsBulkUpdate.test.js index 75c0e6f8..5aa41ca7 100644 --- a/__tests__/functions/spark/transactionsBulkUpdate.test.js +++ b/__tests__/functions/spark/transactionsBulkUpdate.test.js @@ -187,3 +187,110 @@ describe('Spark transaction bulk update guards', () => { }); }); }); + +describe('hasPaidSparkLightningInvoice', () => { + it('asks SQLite for a single matching lightning invoice instead of loading transactions', async () => { + const mockDb = createMockDb(); + mockDb.getAllAsync.mockResolvedValue([{ found: 1 }]); + const { hasPaidSparkLightningInvoice } = loadTransactionsModule(mockDb); + + const result = await hasPaidSparkLightningInvoice(' lnbc123 '); + + expect(result).toBe(true); + expect(mockDb.getAllAsync).toHaveBeenCalledTimes(1); + + const [sql, params] = mockDb.getAllAsync.mock.calls[0]; + expect(sql).toContain('SELECT 1 as found'); + expect(sql).toContain("paymentType = 'lightning'"); + expect(sql).toContain("TRIM(json_extract(details, '$.address')) = ?"); + expect(sql).toContain('LIMIT 1'); + expect(sql).not.toContain('SELECT *'); + expect(params).toEqual(['lnbc123']); + }); + + it('returns false without opening the database for empty invoice addresses', async () => { + const mockDb = createMockDb(); + const { hasPaidSparkLightningInvoice } = loadTransactionsModule(mockDb); + + await expect(hasPaidSparkLightningInvoice(' ')).resolves.toBe(false); + + expect(mockOpenDatabaseAsync).not.toHaveBeenCalled(); + expect(mockDb.getAllAsync).not.toHaveBeenCalled(); + }); +}); + +describe('getSparkTransactionBySparkId', () => { + it('returns the raw row for a single sparkID/accountId lookup', async () => { + const mockDb = createMockDb(); + const row = { + sparkID: 'btc-txid', + accountId: 'identity-pubkey', + paymentStatus: 'pending', + paymentType: 'bitcoin', + details: JSON.stringify({ amount: 2500 }), + }; + mockDb.getAllAsync.mockResolvedValue([row]); + const { getSparkTransactionBySparkId } = loadTransactionsModule(mockDb); + + const result = await getSparkTransactionBySparkId( + ' btc-txid ', + 'identity-pubkey', + ); + + expect(result).toBe(row); + expect(mockDb.getAllAsync).toHaveBeenCalledTimes(1); + + const [sql, params] = mockDb.getAllAsync.mock.calls[0]; + expect(sql).toContain('SELECT *'); + expect(sql).toContain('WHERE sparkID = ? AND accountId = ?'); + expect(sql).toContain('LIMIT 1'); + expect(params).toEqual(['btc-txid', 'identity-pubkey']); + }); + + it('returns null without opening the database for missing lookup input', async () => { + const mockDb = createMockDb(); + const { getSparkTransactionBySparkId } = loadTransactionsModule(mockDb); + + await expect( + getSparkTransactionBySparkId('', 'identity-pubkey'), + ).resolves.toBe(null); + await expect(getSparkTransactionBySparkId('btc-txid')).resolves.toBe(null); + + expect(mockOpenDatabaseAsync).not.toHaveBeenCalled(); + expect(mockDb.getAllAsync).not.toHaveBeenCalled(); + }); +}); + +describe('getLatestSavedLRC20TransactionId', () => { + it('returns the latest saved token transaction id from SQLite', async () => { + const mockDb = createMockDb(); + mockDb.getAllAsync.mockResolvedValue([{ sparkID: 'token-tx-hash' }]); + const { getLatestSavedLRC20TransactionId } = + loadTransactionsModule(mockDb); + + const result = await getLatestSavedLRC20TransactionId('identity-pubkey'); + + expect(result).toBe('token-tx-hash'); + expect(mockDb.getAllAsync).toHaveBeenCalledTimes(1); + + const [sql, params] = mockDb.getAllAsync.mock.calls[0]; + expect(sql).toContain('SELECT sparkID'); + expect(sql).toContain('accountId = ?'); + expect(sql).toContain("paymentType = 'spark'"); + expect(sql).toContain('LENGTH(sparkID) >= 40'); + expect(sql).toContain("ORDER BY json_extract(details, '$.time') DESC"); + expect(sql).toContain('LIMIT 1'); + expect(params).toEqual(['identity-pubkey']); + }); + + it('returns null without opening the database for a missing account id', async () => { + const mockDb = createMockDb(); + const { getLatestSavedLRC20TransactionId } = + loadTransactionsModule(mockDb); + + await expect(getLatestSavedLRC20TransactionId()).resolves.toBe(null); + + expect(mockOpenDatabaseAsync).not.toHaveBeenCalled(); + expect(mockDb.getAllAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/admin/homeComponents/apps/onlineListings/onlineListingsFilterHalfModal.js b/app/components/admin/homeComponents/apps/onlineListings/onlineListingsFilterHalfModal.js index fb939c16..b0850cba 100644 --- a/app/components/admin/homeComponents/apps/onlineListings/onlineListingsFilterHalfModal.js +++ b/app/components/admin/homeComponents/apps/onlineListings/onlineListingsFilterHalfModal.js @@ -35,6 +35,7 @@ import { SIZES, } from '../../../../../constants/theme'; import CheckMarkCircle from '../../../../../functions/CustomElements/checkMarkCircle'; +import { KeyboardController } from 'react-native-keyboard-controller'; const DEFAULT_FILTER = { categories: [], @@ -210,20 +211,28 @@ export default function OnlineListingsFilterHalfModal({ }, []); const handleCountryPress = useCallback( - countryCode => { - setDraft(prev => ({ ...prev, countryCode })); - setCountrySearch(''); - setShowCountryPicker(false); - setIsKeyboardActive?.(false); + async countryCode => { + await KeyboardController.dismiss(); + requestAnimationFrame(() => { + setDraft(prev => ({ ...prev, countryCode })); + setCountrySearch(''); + setShowCountryPicker(false); + setIsKeyboardActive?.(false); + }); }, [setIsKeyboardActive], ); const handleCountryBackPress = useCallback(() => { if (!showCountryPicker) return false; - setShowCountryPicker(false); - setCountrySearch(''); - setIsKeyboardActive?.(false); + KeyboardController.dismiss().then(resp => { + requestAnimationFrame(() => { + setShowCountryPicker(false); + setCountrySearch(''); + setIsKeyboardActive?.(false); + }); + }); + return true; }, [setIsKeyboardActive, showCountryPicker]); diff --git a/app/components/admin/homeComponents/contacts/contactsPageComponents/utilityFunctions.js b/app/components/admin/homeComponents/contacts/contactsPageComponents/utilityFunctions.js index 7ae1dc52..f622f670 100644 --- a/app/components/admin/homeComponents/contacts/contactsPageComponents/utilityFunctions.js +++ b/app/components/admin/homeComponents/contacts/contactsPageComponents/utilityFunctions.js @@ -1,3 +1,4 @@ +import { uses24HourClock } from 'react-native-localize'; import { formatLocalTimeNumeric } from '../../../../../functions/timeFormatter'; // Utility functions @@ -46,11 +47,15 @@ export function createFormattedDate(time, currentTime, t) { formattedTime = t('constants.yesterday'); } else if (isSameDay(date, currentDate)) { const hours = date.getHours(); - const minutes = date.getMinutes(); - const ampm = hours >= 12 ? 'PM' : 'AM'; - const formattedHours = hours % 12 === 0 ? 12 : hours % 12; - const formattedMinutes = minutes < 10 ? '0' + minutes : minutes; - formattedTime = `${formattedHours}:${formattedMinutes} ${ampm}`; + const minutes = String(date.getMinutes()).padStart(2, '0'); + + if (uses24HourClock()) { + formattedTime = `${hours}:${minutes}`; + } else { + const ampm = hours >= 12 ? 'PM' : 'AM'; + const formattedHours = hours % 12 === 0 ? 12 : hours % 12; + formattedTime = `${formattedHours}:${minutes} ${ampm}`; + } } else { const daysDiff = getDaysDifference(currentDate, date); if (daysDiff === 1) { diff --git a/app/components/admin/homeComponents/homeLightning.js b/app/components/admin/homeComponents/homeLightning.js index cf073a4a..1cb13a27 100644 --- a/app/components/admin/homeComponents/homeLightning.js +++ b/app/components/admin/homeComponents/homeLightning.js @@ -157,6 +157,7 @@ export default function HomeLightning({ navigation }) { sparkInformation, showTokensInformation, isSendingPaymentRef, + updateHomepageScrollPosition, // numberOfCachedTxs } = useSparkWallet(); const { poolInfoRef } = useFlashnet(); @@ -190,13 +191,15 @@ export default function HomeLightning({ navigation }) { const prevBg = useSharedValue(false); const [navbarHeight, setNavbarHeight] = useState(0); const [scrollPosition, setScrollPosition] = useState('total'); - const scrollPositionRef = useRef('total'); - const updateScrollPosition = useCallback(page => { - const pos = page === 0 ? 'total' : page === 1 ? 'sats' : 'usd'; - scrollPositionRef.current = pos; - setScrollPosition(pos); - }, []); + const updateScrollPosition = useCallback( + page => { + const pos = page === 0 ? 'total' : page === 1 ? 'sats' : 'usd'; + setScrollPosition(pos); + updateHomepageScrollPosition(pos); + }, + [updateHomepageScrollPosition], + ); const onBalancePageScroll = usePagerScrollHandler({ onPageScroll: e => { @@ -349,7 +352,6 @@ export default function HomeLightning({ navigation }) { darkModeType, t, showTokensInformation, - scrollPosition, ]); const handleRefresh = useCallback(async () => { @@ -613,7 +615,7 @@ export default function HomeLightning({ navigation }) { theme={theme} darkModeType={darkModeType} isConnectedToTheInternet={isConnectedToTheInternet} - scrollPositionRef={scrollPositionRef} + scrollPosition={scrollPosition} /> diff --git a/app/components/admin/homeComponents/homeLightning/sendReciveBTNs.js b/app/components/admin/homeComponents/homeLightning/sendReciveBTNs.js index ca43a5c7..fc58c48f 100644 --- a/app/components/admin/homeComponents/homeLightning/sendReciveBTNs.js +++ b/app/components/admin/homeComponents/homeLightning/sendReciveBTNs.js @@ -14,7 +14,7 @@ export function SendRecieveBTNs({ darkModeType, isConnectedToTheInternet, isNWCWallet = false, - scrollPositionRef, + scrollPosition, }) { const navigate = useNavigation(); const { t } = useTranslation(); @@ -60,11 +60,11 @@ export function SendRecieveBTNs({ return; } navigate.navigate('CustomHalfModal', { - scrollPosition: scrollPositionRef?.current === 'usd' ? 'USD' : 'BTC', + scrollPosition: scrollPosition === 'usd' ? 'USD' : 'BTC', wantedContent: 'receiveOptions', sliderHight: 0.8, }); - }, [handleSettingsCheck, navigate, scrollPositionRef, t]); + }, [handleSettingsCheck, navigate, scrollPosition, t]); const handleCamera = useCallback(() => { const areSettingsSet = handleSettingsCheck(); diff --git a/app/components/admin/homeComponents/homeLightning/sparkErrorScreen.js b/app/components/admin/homeComponents/homeLightning/sparkErrorScreen.js index e66aa672..85c9e217 100644 --- a/app/components/admin/homeComponents/homeLightning/sparkErrorScreen.js +++ b/app/components/admin/homeComponents/homeLightning/sparkErrorScreen.js @@ -19,7 +19,7 @@ import { INSET_WINDOW_WIDTH } from '../../../../constants/theme'; export default function SparkErrorScreen(props) { const { accountMnemoinc } = useKeysContext(); - const { setSparkInformation } = useSparkWallet(); + const { setSparkInformation, filterAndSetTransactions } = useSparkWallet(); const { showToast } = useToast(); const { backgroundColor, backgroundOffset, transparentOveraly } = GetThemeColors(); @@ -37,6 +37,7 @@ export default function SparkErrorScreen(props) { if (retryCount < 1) { const { didWork, error } = await initWallet({ setSparkInformation, + filterAndSetTransactions, // toggleGlobalContactsInformation, // globalContactsInformation, mnemonic: accountMnemoinc, diff --git a/app/components/admin/homeComponents/sendBitcoin/functions/hasPaid.js b/app/components/admin/homeComponents/sendBitcoin/functions/hasPaid.js index b2e222ea..e502e6b8 100644 --- a/app/components/admin/homeComponents/sendBitcoin/functions/hasPaid.js +++ b/app/components/admin/homeComponents/sendBitcoin/functions/hasPaid.js @@ -2,22 +2,13 @@ import { crashlyticsLogReport, crashlyticsRecordErrorReport, } from '../../../../../functions/crashlyticsLogs'; -import { getAllSparkTransactions } from '../../../../../functions/spark/transactions'; +import { hasPaidSparkLightningInvoice } from '../../../../../functions/spark/transactions'; export default async function hasAlredyPaidInvoice({ scannedAddress }) { try { crashlyticsLogReport('Begining already paid invoice function'); - const allTransactions = await getAllSparkTransactions(); - - const didPayWithSpark = allTransactions.find(tx => { - return ( - tx.paymentType === 'lightning' && - JSON.parse(tx.details).address?.trim() === scannedAddress?.trim() - ); - }); - - return !!didPayWithSpark; + return await hasPaidSparkLightningInvoice(scannedAddress); } catch (err) { console.log('already paid invoice error', err); crashlyticsRecordErrorReport(err.message); diff --git a/app/components/admin/homeComponents/settingsContent/displayOptions.js b/app/components/admin/homeComponents/settingsContent/displayOptions.js index 700f0c12..c2223b61 100644 --- a/app/components/admin/homeComponents/settingsContent/displayOptions.js +++ b/app/components/admin/homeComponents/settingsContent/displayOptions.js @@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'; import CheckMarkCircle from '../../../../functions/CustomElements/checkMarkCircle'; import displayCorrectDenomination from '../../../../functions/displayCorrectDenomination'; import DropdownMenu from '../../../../functions/CustomElements/dropdownMenu'; +import { useSparkWallet } from '../../../../../context-store/sparkContext'; // Settings Section Component const SettingsSection = ({ title, children, style }) => { @@ -88,6 +89,7 @@ export default function DisplayOptions() { const initialValueRef = useRef(masterInfoObject.userBalanceDenomination); const saveTimeoutRef = useRef(null); const navigate = useNavigation(); + const { updateHomepageTxPreferance } = useSparkWallet(); const dropdownOptions = [15, 20, 25, 30, 35, 40].map(value => ({ label: t('settings.displayOptions.transactionsLabel', { context: value }), @@ -268,9 +270,10 @@ export default function DisplayOptions() { selectedValue={t('settings.displayOptions.transactionsLabel', { context: masterInfoObject.homepageTxPreferance, })} - onSelect={item => - toggleMasterInfoObject({ homepageTxPreferance: item.value }) - } + onSelect={item => { + toggleMasterInfoObject({ homepageTxPreferance: item.value }); + updateHomepageTxPreferance(item.value); + }} customButtonStyles={{ backgroundColor: theme ? backgroundColor : COLORS.darkModeText, }} diff --git a/app/functions/customNavigation.js b/app/functions/customNavigation.js index 512ca1dd..d7351dde 100644 --- a/app/functions/customNavigation.js +++ b/app/functions/customNavigation.js @@ -2,10 +2,10 @@ import { KeyboardController } from 'react-native-keyboard-controller'; export async function keyboardGoBack(navigate) { await KeyboardController.dismiss(); - setTimeout(navigate.goBack, KeyboardController.isVisible() ? 60 : 0); + setTimeout(navigate.goBack, KeyboardController.isVisible() ? 30 : 0); } export async function keyboardNavigate(navigatorFunction) { await KeyboardController.dismiss(); - setTimeout(navigatorFunction, KeyboardController.isVisible() ? 60 : 0); + setTimeout(navigatorFunction, KeyboardController.isVisible() ? 30 : 0); } diff --git a/app/functions/detectQrCode.js b/app/functions/detectQrCode.js index cf7a2708..22947f92 100644 --- a/app/functions/detectQrCode.js +++ b/app/functions/detectQrCode.js @@ -1,7 +1,10 @@ import * as ImageManipulator from 'expo-image-manipulator'; +import {deleteAsync} from 'expo-file-system/legacy'; import RNQRGenerator from 'rn-qr-generator'; export async function detectQRCode(uri) { + let temporaryImageUri; + try { const resized = ImageManipulator.ImageManipulator.manipulate(uri).resize({ width: 400, @@ -12,9 +15,10 @@ export async function detectQRCode(uri) { compress: 0.5, format: ImageManipulator.SaveFormat.WEBP, }); + temporaryImageUri = savedImage.uri; const response = await RNQRGenerator.detect({ - uri: savedImage.uri, + uri: temporaryImageUri, }); return response; @@ -25,5 +29,13 @@ export async function detectQRCode(uri) { console.error('QR detection failed:', error); } return null; + } finally { + if (temporaryImageUri) { + try { + await deleteAsync(temporaryImageUri, {idempotent: true}); + } catch (cleanupError) { + console.warn('Failed to delete temporary QR scan image:', cleanupError); + } + } } } diff --git a/app/functions/initiateWalletConnection.js b/app/functions/initiateWalletConnection.js index 145e2c21..f277c781 100644 --- a/app/functions/initiateWalletConnection.js +++ b/app/functions/initiateWalletConnection.js @@ -12,6 +12,7 @@ import { getAccountBalanceSnapshot } from './spark/balanceSnapshots'; export async function initWallet({ setSparkInformation, + filterAndSetTransactions, // toggleGlobalContactsInformation, // globalContactsInformation, mnemonic, @@ -32,6 +33,7 @@ export async function initWallet({ })); const didSetSpark = await initializeSparkSession({ setSparkInformation, + filterAndSetTransactions, // globalContactsInformation, // toggleGlobalContactsInformation, mnemonic, @@ -59,6 +61,7 @@ export async function initWallet({ export async function initializeSparkSession({ setSparkInformation, + filterAndSetTransactions, mnemonic, sendWebViewRequest, hasRestoreCompleted, @@ -116,8 +119,10 @@ export async function initializeSparkSession({ setSparkInformation(prev => ({ ...prev, ...storageObject, - transactions: transactions ?? prev.transactions, })); + const txToUse = transactions ?? []; + if (txToUse.length && filterAndSetTransactions) + filterAndSetTransactions(txToUse); return storageObject; } @@ -130,15 +135,15 @@ export async function initializeSparkSession({ initialBalance: Number(balance.balance), }; - setSparkInformation(prev => { - const txToUse = - !hasRestoreCompleted || - (prev.identityPubKey && prev.identityPubKey !== identityPubKey) - ? transactions ?? prev.transactions - : prev.transactions; + const txToUse = + !hasRestoreCompleted || + (cachedIdentityPubKey && cachedIdentityPubKey !== identityPubKey) + ? transactions + : null; - return { ...prev, ...storageObject, transactions: txToUse }; - }); + setSparkInformation(prev => ({...prev, ...storageObject})); + if (txToUse && filterAndSetTransactions) + filterAndSetTransactions(txToUse); return storageObject; } catch (err) { console.log('Set spark error', err); diff --git a/app/functions/lrc20/index.js b/app/functions/lrc20/index.js index 4a6d9588..2507cf40 100644 --- a/app/functions/lrc20/index.js +++ b/app/functions/lrc20/index.js @@ -1,14 +1,13 @@ import { AppState } from 'react-native'; import { IS_SPARK_ID, USDB_TOKEN_ID } from '../../constants'; -import { - getCachedSparkTransactions, - getSparkTokenTransactions, -} from '../spark'; +import { getSparkTokenTransactions } from '../spark'; import { getActiveSwapTransferIds, isSwapActive } from '../spark/flashnet'; import { bulkUpdateSparkTransactions, deleteSparkContactTransaction, getAllSparkContactInvoices, + getLatestSavedLRC20TransactionId, + getSparkTransactionBySparkId, } from '../spark/transactions'; import { convertToBech32m } from './bech32'; import tokenBufferAmountToDecimal from './bufferToDecimal'; @@ -24,24 +23,10 @@ export async function getLRC20Transactions({ if (isRunning) throw new Error('process is already running'); isRunning = true; if (AppState.currentState !== 'active') return; - const savedTxs = await getCachedSparkTransactions(null, ownerPublicKeys[0]); - - // Find last saved token transaction (any status, including failed flashnet pairs) - let lastSavedTransactionId = null; - if (savedTxs) { - for (const tx of savedTxs) { - if ( - tx.paymentType !== 'spark' || - IS_SPARK_ID.test(tx.sparkID) || - tx.sparkID.length < 40 - ) { - continue; - } - - lastSavedTransactionId = tx.sparkID; - break; - } - } + const ownerPubKey = ownerPublicKeys[0]; + const lastSavedTransactionId = await getLatestSavedLRC20TransactionId( + ownerPubKey, + ); const tokenTxs = await getSparkTokenTransactions({ ownerPublicKeys, @@ -53,23 +38,24 @@ export async function getLRC20Transactions({ if (!tokenTxs?.tokenTransactionsWithStatus) return; const tokenTransactions = tokenTxs.tokenTransactionsWithStatus; - // Build savedIds set for all saved LRC20 token txs (any status) to avoid reprocessing - const savedIds = new Set(); - if (savedTxs) { - for (const tx of savedTxs) { - if (tx.paymentType !== 'spark' || IS_SPARK_ID.test(tx.sparkID)) { - continue; - } - - savedIds.add(tx.sparkID); - } - } - const newTxs = []; - const ownerPubKey = ownerPublicKeys[0]; const isSwapInProgress = isSwapActive(); const activeSwaps = getActiveSwapTransferIds(); const unpaidContactInvoices = await getAllSparkContactInvoices(); + const savedTxCache = new Map(); + const getSavedLRC20Tx = async txHash => { + if (!txHash) return null; + if (savedTxCache.has(txHash)) return savedTxCache.get(txHash); + + const savedTx = await getSparkTransactionBySparkId(txHash, ownerPubKey); + const savedLRC20Tx = + savedTx?.paymentType === 'spark' && !IS_SPARK_ID.test(savedTx.sparkID) + ? savedTx + : null; + + savedTxCache.set(txHash, savedLRC20Tx); + return savedLRC20Tx; + }; for (const tokenTx of tokenTransactions) { const tokenOutput = tokenTx.tokenTransaction.tokenOutputs[0]; @@ -89,7 +75,7 @@ export async function getLRC20Transactions({ ).toString('hex'); // Skip if already saved - if (savedIds.has(txHash)) continue; + if (await getSavedLRC20Tx(txHash)) continue; const tokenOutputs = tokenTx.tokenTransaction.tokenOutputs; diff --git a/app/functions/spark/filterTransactions.js b/app/functions/spark/filterTransactions.js new file mode 100644 index 00000000..2cd704c2 --- /dev/null +++ b/app/functions/spark/filterTransactions.js @@ -0,0 +1,98 @@ +import {USDB_TOKEN_ID} from '../../constants'; +import {isFlashnetTransfer} from './handleFlashnetTransferIds'; + +export function filterDisplayableTransactions({ + transactions, + scrollPosition, + enabledLRC20, + tokens, + forcedPendingMap, + appliedEpoch = 0, + limit = 25, +}) { + if (!transactions?.length) return []; + + const shownTxs = new Set(); + const lnFundingTxIds = new Set(); + const result = []; + + for (let i = 0; i < transactions.length && result.length < limit; i++) { + const tx = transactions[i]; + + let paymentDetails; + try { + paymentDetails = JSON.parse(tx.details); + } catch { + continue; + } + + const paymentType = tx.paymentType; + const paymentStatus = tx.paymentStatus; + const isLRC20Payment = paymentDetails.isLRC20Payment; + const hasSavedTokenData = tokens?.[paymentDetails.LRC20Token]; + + if (paymentDetails?.ln_funding_id) { + lnFundingTxIds.add(paymentDetails.ln_funding_id); + } + + const showSwapConversion = + paymentDetails.performSwaptoUSD && + (!paymentDetails.completedSwaptoUSD || + !lnFundingTxIds.has(tx.sparkID)); + + // Static filters + if ( + !enabledLRC20 && + isLRC20Payment && + paymentDetails.LRC20Token !== USDB_TOKEN_ID + ) + continue; + if (paymentType === 'unknown') continue; + if ( + paymentDetails.senderIdentityPublicKey === + process.env.SPARK_IDENTITY_PUBKEY + ) + continue; + if (shownTxs.has(tx.sparkID)) continue; + if (isLRC20Payment && !hasSavedTokenData) continue; + if (paymentStatus === 'failed') continue; + if ( + paymentType === 'lightning' && + tx.status === 'LIGHTNING_PAYMENT_INITIATED' + ) + continue; + if (isFlashnetTransfer(tx.sparkID)) continue; + + // Scroll position filters + if ( + scrollPosition === 'total' && + paymentDetails.showSwapLabel && + paymentDetails.direction === 'OUTGOING' + ) + continue; + if ( + (scrollPosition === 'sats' && isLRC20Payment) || + (scrollPosition === 'sats' && showSwapConversion) + ) + continue; + if ( + (scrollPosition === 'usd' && + isLRC20Payment && + paymentDetails.LRC20Token !== USDB_TOKEN_ID) || + (scrollPosition === 'usd' && !isLRC20Payment && !showSwapConversion) + ) + continue; + + shownTxs.add(tx.sparkID); + + // Apply pending flag + const pendingMeta = forcedPendingMap?.get(tx.sparkID); + if (pendingMeta && pendingMeta.epoch > appliedEpoch && !tx.isBalancePending) { + result.push({...tx, isBalancePending: true}); + } else { + result.push(tx); + } + } + + return result; +} diff --git a/app/functions/spark/restore.js b/app/functions/spark/restore.js index 838492ce..9aaa19e5 100644 --- a/app/functions/spark/restore.js +++ b/app/functions/spark/restore.js @@ -1,5 +1,4 @@ import { - getCachedSparkTransactions, getSingleTxDetails, getSparkBitcoinPaymentRequest, getSparkLightningPaymentStatus, diff --git a/app/functions/spark/transactions.js b/app/functions/spark/transactions.js index b8171668..d2740da0 100644 --- a/app/functions/spark/transactions.js +++ b/app/functions/spark/transactions.js @@ -189,6 +189,23 @@ export const initializeSparkDatabase = async () => { tokens TEXT NOT NULL DEFAULT '{}', updatedAt INTEGER NOT NULL ); + + CREATE INDEX IF NOT EXISTS idx_spark_tx_lightning_invoice_address + ON ${SPARK_TRANSACTIONS_TABLE_NAME} ( + paymentType, + TRIM(json_extract(details, '$.address')) + ) + WHERE paymentType = 'lightning' AND json_valid(details); + + CREATE INDEX IF NOT EXISTS idx_spark_tx_spark_id_account_id + ON ${SPARK_TRANSACTIONS_TABLE_NAME} (sparkID, accountId); + + CREATE INDEX IF NOT EXISTS idx_spark_tx_lrc20_latest + ON ${SPARK_TRANSACTIONS_TABLE_NAME} ( + accountId, + paymentType, + json_extract(details, '$.time') + ); `); console.log('Opened spark transaction and contacts tables'); @@ -298,6 +315,84 @@ export const getAllSparkTransactions = async (options = {}) => { } }; +export const hasPaidSparkLightningInvoice = async invoiceAddress => { + const trimmedInvoiceAddress = + typeof invoiceAddress === 'string' ? invoiceAddress.trim() : ''; + + if (!trimmedInvoiceAddress) return false; + + try { + await ensureSparkDatabaseReady(); + + const result = await sqlLiteDB.getAllAsync( + `SELECT 1 as found + FROM ${SPARK_TRANSACTIONS_TABLE_NAME} + WHERE paymentType = 'lightning' + AND json_valid(details) + AND TRIM(json_extract(details, '$.address')) = ? + LIMIT 1`, + [trimmedInvoiceAddress], + ); + + return result?.length > 0; + } catch (error) { + console.error('Error checking paid spark lightning invoice:', error); + return false; + } +}; + +export const getSparkTransactionBySparkId = async (sparkID, accountId) => { + const normalizedSparkID = typeof sparkID === 'string' ? sparkID.trim() : ''; + const normalizedAccountId = + accountId !== undefined && accountId !== null ? String(accountId) : ''; + + if (!normalizedSparkID || !normalizedAccountId) return null; + + try { + await ensureSparkDatabaseReady(); + + const rows = await sqlLiteDB.getAllAsync( + `SELECT * + FROM ${SPARK_TRANSACTIONS_TABLE_NAME} + WHERE sparkID = ? AND accountId = ? + LIMIT 1`, + [normalizedSparkID, normalizedAccountId], + ); + + return rows?.[0] ?? null; + } catch (error) { + console.error('Error fetching spark transaction by sparkID:', error); + return null; + } +}; + +export const getLatestSavedLRC20TransactionId = async accountId => { + const normalizedAccountId = + accountId !== undefined && accountId !== null ? String(accountId) : ''; + + if (!normalizedAccountId) return null; + + try { + await ensureSparkDatabaseReady(); + + const rows = await sqlLiteDB.getAllAsync( + `SELECT sparkID + FROM ${SPARK_TRANSACTIONS_TABLE_NAME} + WHERE accountId = ? + AND paymentType = 'spark' + AND LENGTH(sparkID) >= 40 + ORDER BY json_extract(details, '$.time') DESC + LIMIT 1`, + [normalizedAccountId], + ); + + return rows?.[0]?.sparkID ?? null; + } catch (error) { + console.error('Error fetching latest saved LRC20 transaction ID:', error); + return null; + } +}; + const DATE_OFFSETS = { '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, diff --git a/app/hooks/useAccountSwitcher.js b/app/hooks/useAccountSwitcher.js index 0e4e4dd0..f9e3a8f5 100644 --- a/app/hooks/useAccountSwitcher.js +++ b/app/hooks/useAccountSwitcher.js @@ -11,7 +11,8 @@ import { navigationRef } from '../../navigation/navigationService'; export default function useAccountSwitcher() { const navigate = useNavigation(); - const { setSparkInformation, sparkInformation } = useSparkWallet(); + const { setSparkInformation, sparkInformation, filterAndSetTransactions } = + useSparkWallet(); const { currentWalletMnemoinc, selectedAltAccount, @@ -60,6 +61,7 @@ export default function useAccountSwitcher() { const initResponse = await initWallet({ setSparkInformation, + filterAndSetTransactions, mnemonic: accountMnemonic, hasRestoreCompleted: false, }); diff --git a/app/screens/inAccount/viewAllTxPage.js b/app/screens/inAccount/viewAllTxPage.js index 9538065b..04f75214 100644 --- a/app/screens/inAccount/viewAllTxPage.js +++ b/app/screens/inAccount/viewAllTxPage.js @@ -65,7 +65,10 @@ export default function ViewAllTxPage() { let transactions; if (!hasActiveFilters) { - const txs = await getAllSparkTransactions({ limit: null }); + const txs = await getAllSparkTransactions({ + limit: null, + accountId: sparkInformation.identityPubKey, + }); transactions = txs; } else { transactions = await getFilteredTransactions( diff --git a/context-store/activeAccount.js b/context-store/activeAccount.js index 0a23596f..8a499820 100644 --- a/context-store/activeAccount.js +++ b/context-store/activeAccount.js @@ -550,29 +550,50 @@ export const ActiveCustodyAccountProvider = ({ children }) => { }); }, [custodyAccountsList, isUsingNostr, selectedAltAccount]); + const accountValues = useMemo(() => { + return { + custodyAccounts, + removeAccount, + createAccount, + updateAccount, + updateAccountCacheOnly, + createDerivedAccount, + createImportedAccount, + restoreDerivedAccount, + getAccountMnemonic, + restoreDerivedAccountsFromCloud, + selectedAltAccount, + isUsingAltAccount, + currentWalletMnemoinc, + toggleIsUsingNostr, + isUsingNostr, + nostrSeed, + activeAccount, + custodyAccountsList, + }; + }, [ + custodyAccounts, + removeAccount, + createAccount, + updateAccount, + updateAccountCacheOnly, + createDerivedAccount, + createImportedAccount, + restoreDerivedAccount, + getAccountMnemonic, + restoreDerivedAccountsFromCloud, + selectedAltAccount, + isUsingAltAccount, + currentWalletMnemoinc, + toggleIsUsingNostr, + isUsingNostr, + nostrSeed, + activeAccount, + custodyAccountsList, + ]); + return ( - + {children} ); diff --git a/context-store/analyticsContext.js b/context-store/analyticsContext.js index 56642cdc..3f49bbfb 100644 --- a/context-store/analyticsContext.js +++ b/context-store/analyticsContext.js @@ -223,30 +223,48 @@ export function AnalyticsProvider({ children }) { } }, [spentTotalBTC, spentTotalUSD]); + const accountsValues = useMemo(() => { + return { + spentTotal, + inTxsBTC, + outTxsBTC, + inTxsUSD, + outTxsUSD, + incomeTotalBTC, + incomeTotalUSD, + spentTotalBTC, + spentTotalUSD, + incomeTxCountBTC: inTxsBTC.length, + spentTxCountBTC: outTxsBTC.length, + incomeTxCountUSD: inTxsUSD.length, + spentTxCountUSD: outTxsUSD.length, + cumulativeIncomeDataBTC, + cumulativeSpentDataBTC, + cumulativeIncomeDataUSD, + cumulativeSpentDataUSD, + isLoading, + isReloading, + }; + }, [ + spentTotal, + inTxsBTC, + outTxsBTC, + inTxsUSD, + outTxsUSD, + incomeTotalBTC, + incomeTotalUSD, + spentTotalBTC, + spentTotalUSD, + cumulativeIncomeDataBTC, + cumulativeSpentDataBTC, + cumulativeIncomeDataUSD, + cumulativeSpentDataUSD, + isLoading, + isReloading, + ]); + return ( - + {children} ); diff --git a/context-store/sparkContext.js b/context-store/sparkContext.js index 85de6867..ff7d68a0 100644 --- a/context-store/sparkContext.js +++ b/context-store/sparkContext.js @@ -25,6 +25,7 @@ import { insertSparkTransactionPlaceholders, getAllSparkTransactions, getAllSparkContactInvoices, + getSparkTransactionBySparkId, getAllUnpaidSparkLightningInvoices, SPARK_TX_UPDATE_ENVENT_NAME, sparkTransactionsEventEmitter, @@ -36,10 +37,7 @@ import { updateSparkTxStatus, } from '../app/functions/spark/restore'; import { useGlobalContactsInfo } from './globalContacts'; -import { - initializeSparkSession, - initWallet, -} from '../app/functions/initiateWalletConnection'; +import { initWallet } from '../app/functions/initiateWalletConnection'; // import { useNodeContext } from './nodeContext'; import { AppState } from 'react-native'; import getDepositAddressTxIds from '../app/functions/spark/getDepositAdressTxIds'; @@ -73,6 +71,7 @@ import { runTokenOptimization, } from '../app/functions/spark/optimization'; import { isFlashnetTransfer } from '../app/functions/spark/handleFlashnetTransferIds'; +import { filterDisplayableTransactions } from '../app/functions/spark/filterTransactions'; export const isSendingPayingEventEmiiter = new EventEmitter(); export const SENDING_PAYMENT_EVENT_NAME = 'SENDING_PAYMENT_EVENT'; @@ -197,12 +196,14 @@ const SparkWalletProvider = ({ children }) => { const balanceSupervisorRunIdRef = useRef(0); const forcedPendingBySparkIdRef = useRef(new Map()); const lastConfirmedTxBoundaryRef = useRef(null); + const scrollPositionRef = useRef('total'); const isBalancePollerRunningRef = useRef(false); const lastBalancePollEventRef = useRef({ updateType: null, timestamp: 0, }); + const homepageTxPreferance = masterInfoObject.homepageTxPreferance; const showTokensInformation = masterInfoObject.enabledBTKNTokens === null @@ -256,18 +257,17 @@ const SparkWalletProvider = ({ children }) => { useEffect(() => { sparkInfoRef.current = { + ...sparkInfoRef.current, balance: sparkInformation.balance, tokens: sparkInformation.tokens, identityPubKey: sparkInformation.identityPubKey, sparkAddress: sparkInformation.sparkAddress, - transactions: sparkInformation.transactions?.slice(0, 50), }; }, [ sparkInformation.balance, sparkInformation.tokens, sparkInformation.identityPubKey, sparkInformation.sparkAddress, - sparkInformation.transactions, ]); useEffect(() => { @@ -621,6 +621,65 @@ const SparkWalletProvider = ({ children }) => { }); }; + const filterAndSetTransactions = useCallback( + freshTxs => { + sparkInfoRef.current.transactions = freshTxs.slice(0, 50); + const filtered = filterDisplayableTransactions({ + transactions: freshTxs, + scrollPosition: scrollPositionRef.current, + enabledLRC20: showTokensInformation, + tokens: sparkInfoRef.current.tokens, + forcedPendingMap: forcedPendingBySparkIdRef.current, + appliedEpoch: balanceEpochRef.current.applied, + limit: homepageTxPreferance, + }); + setSparkInformation(prev => ({ ...prev, transactions: filtered })); + }, + [showTokensInformation, homepageTxPreferance], + ); + + const updateHomepageScrollPosition = useCallback( + async pos => { + scrollPositionRef.current = pos; + const allTxs = await getAllSparkTransactions({ + limit: null, + accountId: sparkInfoRef.current.identityPubKey, + }); + const filtered = filterDisplayableTransactions({ + transactions: allTxs, + scrollPosition: pos, + enabledLRC20: showTokensInformation, + tokens: sparkInfoRef.current.tokens, + forcedPendingMap: forcedPendingBySparkIdRef.current, + appliedEpoch: balanceEpochRef.current.applied, + limit: homepageTxPreferance, + }); + if (scrollPositionRef.current !== pos) return; + setSparkInformation(prev => ({ ...prev, transactions: filtered })); + }, + [showTokensInformation, homepageTxPreferance], + ); + + const updateHomepageTxPreferance = useCallback( + async num => { + const allTxs = await getAllSparkTransactions({ + limit: null, + accountId: sparkInfoRef.current.identityPubKey, + }); + const filtered = filterDisplayableTransactions({ + transactions: allTxs, + scrollPosition: scrollPositionRef.current, + enabledLRC20: showTokensInformation, + tokens: sparkInfoRef.current.tokens, + forcedPendingMap: forcedPendingBySparkIdRef.current, + appliedEpoch: balanceEpochRef.current.applied, + limit: num, + }); + setSparkInformation(prev => ({ ...prev, transactions: filtered })); + }, + [showTokensInformation], + ); + const enqueueTxLane = useCallback((updateType, task) => { queueDepthRef.current += 1; console.log( @@ -856,10 +915,7 @@ const SparkWalletProvider = ({ children }) => { const txListWithPendingFlags = applyForcedPendingFlags(txs); - setSparkInformation(prev => ({ - ...prev, - transactions: txListWithPendingFlags, - })); + filterAndSetTransactions(txs); enqueueUiLane(event.updateType, () => maybeHandleConfirmNavigation( @@ -869,7 +925,7 @@ const SparkWalletProvider = ({ children }) => { ), ); }, - [enqueueUiLane, maybeHandleConfirmNavigation], + [enqueueUiLane, maybeHandleConfirmNavigation, filterAndSetTransactions], ); const applyConfirmedBalanceSnapshot = useCallback( @@ -907,6 +963,8 @@ const SparkWalletProvider = ({ children }) => { 'apply confimred balance snapshot', ); + filterAndSetTransactions(freshTxs); + const myVersion = ++balanceVersionRef.current; setSparkInformation(prev => { if (myVersion < balanceVersionRef.current) return prev; @@ -916,11 +974,15 @@ const SparkWalletProvider = ({ children }) => { ? numericBalance : prev.balance, tokens: result?.didWork ? result.tokensObj : prev.tokens, - transactions: projectedTxs || prev.transactions, }; }); }, - [maybeHandleConfirmNavigation, contactsPrivateKey, publicKey], + [ + maybeHandleConfirmNavigation, + contactsPrivateKey, + publicKey, + filterAndSetTransactions, + ], ); const runBalanceSupervisor = useCallback(async () => { @@ -1011,16 +1073,13 @@ const SparkWalletProvider = ({ children }) => { if (shouldForcePending) { const currentTxs = sparkInfoRef.current.transactions || []; registerForcedPendingForEpoch(currentTxs, epoch); - setSparkInformation(prev => ({ - ...prev, - transactions: applyForcedPendingFlags(prev.transactions || []), - })); + filterAndSetTransactions(sparkInfoRef.current.transactions || []); } runBalanceSupervisor(); return epoch; }, - [runBalanceSupervisor], + [runBalanceSupervisor, filterAndSetTransactions], ); const applyIncomingPaymentSnapshot = useCallback( @@ -1067,12 +1126,13 @@ const SparkWalletProvider = ({ children }) => { getBoundaryFromTxs(freshTxs), ); + filterAndSetTransactions(freshTxs); + const myVersion = ++balanceVersionRef.current; setSparkInformation(prev => { if (myVersion < balanceVersionRef.current) return prev; return { ...prev, - transactions: applyForcedPendingFlags(freshTxs || prev.transactions), balance: Number.isFinite(numericPassedBalance) ? numericPassedBalance : prev.balance, @@ -1082,7 +1142,7 @@ const SparkWalletProvider = ({ children }) => { }; }); }, - [contactsPrivateKey, publicKey], + [contactsPrivateKey, publicKey, filterAndSetTransactions], ); const handleUpdate = useCallback( @@ -1447,6 +1507,7 @@ const SparkWalletProvider = ({ children }) => { tokens: {}, identityPubKey: '', sparkAddress: '', + transactions: [], }; handledTransfers.current = new Set(); prevListenerType.current = null; @@ -1573,10 +1634,18 @@ const SparkWalletProvider = ({ children }) => { if (AppState.currentState !== 'active') return; if (isSendingPaymentRef.current) return; if (!currentMnemonicRef.current) return; - const allTxs = await getAllSparkTransactions({ - accountId: sparkInfoRef.current.identityPubKey, - }); - const savedTxMap = new Map(allTxs.map(tx => [tx.sparkID, tx])); + const savedTxCache = new Map(); + const getSavedTxByTxid = async txid => { + if (!txid) return null; + if (savedTxCache.has(txid)) return savedTxCache.get(txid); + + const savedTx = await getSparkTransactionBySparkId( + txid, + sparkInfoRef.current.identityPubKey, + ); + savedTxCache.set(txid, savedTx); + return savedTx; + }; const depositAddresses = await queryAllStaticDepositAddresses( currentMnemonicRef.current, ); @@ -1610,7 +1679,8 @@ const SparkWalletProvider = ({ children }) => { for (const tx of exploraData) { if (claimableByTxid.has(tx.txid)) continue; // Spark has it, Phase 2 handles it if (allKnownByTxid.has(tx.txid)) continue; // Already claimed by Spark - if (savedTxMap.has(tx.txid)) continue; // Already in our DB + const savedTx = await getSavedTxByTxid(tx.txid); + if (savedTx) continue; // Already in our DB marked as pending console.log( 'Adding pending deposit tx (not yet claimable):', @@ -1628,7 +1698,11 @@ const SparkWalletProvider = ({ children }) => { address, sparkInfoRef.current, ); - savedTxMap.set(tx.txid, true); + savedTxCache.set(tx.txid, { + sparkID: tx.txid, + accountId: sparkInfoRef.current.identityPubKey, + details: JSON.stringify({ amount: tx.amount }), + }); } console.log('Unclaimed UTXOs for address:', address, unclaimedUtxos); @@ -1638,7 +1712,8 @@ const SparkWalletProvider = ({ children }) => { for (const utxo of unclaimedUtxos.utxos) { const { txid, vout } = utxo; const exploraTx = exploraData?.find(t => t.txid === txid); - const hasAlreadySaved = savedTxMap.has(txid); + let savedTx = await getSavedTxByTxid(txid); + const hasAlreadySaved = !!savedTx; // Get quote for this specific UTXO const { @@ -1670,7 +1745,17 @@ const SparkWalletProvider = ({ children }) => { // Add pending transaction if not already saved if (!hasAlreadySaved) { - await addPendingTransaction(quote, address, sparkInfoRef.current); + const pendingTx = await addPendingTransaction( + quote, + address, + sparkInfoRef.current, + ); + savedTx = { + sparkID: pendingTx.id, + accountId: pendingTx.accountId, + details: JSON.stringify(pendingTx.details), + }; + savedTxCache.set(txid, savedTx); } if (!claimTx || !didWork) { @@ -1701,7 +1786,7 @@ const SparkWalletProvider = ({ children }) => { } else { const savedTxDetails = (() => { try { - return JSON.parse(savedTxMap.get(txid)?.details ?? 'null'); + return JSON.parse(savedTx?.details ?? 'null'); } catch { return null; } @@ -1780,6 +1865,7 @@ const SparkWalletProvider = ({ children }) => { }, }; await bulkUpdateSparkTransactions([pendingTx], 'transactions'); + return pendingTx; }; clearAllDepositIntervals(); @@ -1854,7 +1940,7 @@ const SparkWalletProvider = ({ children }) => { null, sparkInfoRef.current.identityPubKey, ); - setSparkInformation(prev => ({ ...prev, transactions })); + filterAndSetTransactions(transactions); hasRestoreCompleted.current = true; } @@ -1883,6 +1969,7 @@ const SparkWalletProvider = ({ children }) => { async identityPubKey => { const { didWork, error } = await initWallet({ setSparkInformation, + filterAndSetTransactions, // toggleGlobalContactsInformation, // globalContactsInformation, mnemonic: accountMnemoinc, @@ -1960,6 +2047,9 @@ const SparkWalletProvider = ({ children }) => { toggleNewestPaymentTimestamp, isSendingPaymentRef, sparkInfoRef, + updateHomepageScrollPosition, + filterAndSetTransactions, + updateHomepageTxPreferance, }), [ sparkInformation, @@ -1976,6 +2066,9 @@ const SparkWalletProvider = ({ children }) => { toggleNewestPaymentTimestamp, isSendingPaymentRef, sparkInfoRef, + updateHomepageScrollPosition, + filterAndSetTransactions, + updateHomepageTxPreferance, ], ); diff --git a/context-store/webViewContext.js b/context-store/webViewContext.js index c308e69e..12ab5d19 100644 --- a/context-store/webViewContext.js +++ b/context-store/webViewContext.js @@ -2,6 +2,7 @@ import React, { createContext, useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -1372,16 +1373,24 @@ export const WebViewProvider = ({ children }) => { [blockAndResetWebview], ); + const providerValues = useMemo(() => { + return { + webViewRef, + sendWebViewRequest: sendWebViewRequestInternal, + fileHash, + changeSparkConnectionState, + didRunHandshakeRef, + }; + }, [ + webViewRef, + sendWebViewRequestInternal, + fileHash, + changeSparkConnectionState, + didRunHandshakeRef, + ]); + return ( - + {children} {verifiedPath && (