import { createContext, useState, useContext, useMemo, useEffect, useRef, useCallback, } from 'react'; import { attachWalletListeners, claimnSparkStaticDepositAddress, clearMnemonicCache, getSingleTxDetails, getSparkTransactions, getSparkStaticBitcoinL1AddressQuote, getUtxosForDepositAddress, initializeFlashnet, queryAllStaticDepositAddresses, selectSparkRuntime, sparkWallet, isOptimizationInProgress, } from '../app/functions/spark'; import { bulkUpdateSparkTransactions, insertSparkTransactionPlaceholders, getAllSparkTransactions, getAllSparkContactInvoices, getSparkTransactionBySparkId, getAllUnpaidSparkLightningInvoices, SPARK_TX_UPDATE_ENVENT_NAME, sparkTransactionsEventEmitter, } from '../app/functions/spark/transactions'; import { useAppStatus } from './appStatus'; import { checkHodlInvoicePaymentStatuses, updateSparkTxStatus, } from '../app/functions/spark/restore'; import { useGlobalContactsInfo } from './globalContacts'; import { initWallet } from '../app/functions/initiateWalletConnection'; // import { useNodeContext } from './nodeContext'; import { AppState, InteractionManager } from 'react-native'; import getDepositAddressTxIds from '../app/functions/spark/getDepositAdressTxIds'; import { useKeysContext } from './keys'; import { clearSpendAndReplaceCorrelationMemo, setSpendAndReplaceAuthGetter, } from '../app/functions/spark/spendAndReplaceCorrelation'; import { navigationRef } from '../navigation/navigationService'; import { transformTxToPaymentObject } from '../app/functions/spark/transformTxToPayment'; import EventEmitter from 'events'; import { getLRC20Transactions } from '../app/functions/lrc20'; import { useActiveCustodyAccount } from './activeAccount'; import sha256Hash from '../app/functions/hash'; import i18n from 'i18next'; import { INCOMING_SPARK_TX_NAME, incomingSparkTransaction, BALANCE_UPDATE_EVENT_NAME, sparkBalanceUpdateEmitter, TOKEN_BALANCE_UPDATE_EVENT_NAME, sparkTokenBalanceUpdateEmitter, STREAM_STATUS_EVENT_NAME, sparkStreamStatusEmitter, OPERATION_TYPES, sendWebViewRequestGlobal, useWebView, } from './webViewContext'; import { useGlobalContextProvider } from './context'; import { useAuthContext } from './authContext'; import { createRestorePoller, getBalanceWithTimeout, getSparkLeavesWithTimeout, getSparkExitNodesForLeavesWithTimeout, } from '../app/functions/pollingManager'; import { replaceAllLeaves, getGlobalLeafStats, getPendingExitNodeLeafIds, saveExitNodesForLeaf, getExitNodeSyncProgress, } from '../app/functions/spark/leavesStorage'; import { USDB_TOKEN_ID } from '../app/constants'; import { saveAccountBalanceSnapshot } from '../app/functions/spark/balanceSnapshots'; import { mergeAndCacheTokens } from '../app/functions/lrc20/cachedTokens'; import { cleanupOptimization, checkIfOptimizationNeeded, runLeafOptimization, runTokenOptimization, } from '../app/functions/spark/optimization'; import { isFlashnetTransfer } from '../app/functions/spark/handleFlashnetTransferIds'; import { filterDisplayableTransactions } from '../app/functions/spark/filterTransactions'; import { getCachedTokenImages } from '../app/functions/spark/tokenImageCache'; import { useToastActions } from './toastManager'; export const isSendingPayingEventEmiiter = new EventEmitter(); export const SENDING_PAYMENT_EVENT_NAME = 'SENDING_PAYMENT_EVENT'; if (!global.blitzWalletSparkIntervalState) { global.blitzWalletSparkIntervalState = { intervalTracker: new Map(), listenerLock: new Map(), allIntervalIds: new Set(), depositIntervalIds: new Set(), }; } const { intervalTracker, listenerLock, allIntervalIds, depositIntervalIds } = global.blitzWalletSparkIntervalState; const TX_REFRESH_UPDATE_TYPES = new Set([ 'transactions', 'txStatusUpdate', 'lrc20Payments', 'contactDetailsUpdate', 'incrementalRestore', 'incomingPayment', 'fullUpdate', 'fullUpdate-waitBalance', 'fullUpdate-tokens', 'paymentWrapperTx', ]); const BALANCE_INTENT_UPDATE_TYPES = new Set([ 'fullUpdate-waitBalance', 'paymentWrapperTx', 'fullUpdate', 'fullUpdate-tokens', ]); const SKIP_CONFIRM_NAV_UPDATE_TYPES = new Set([ 'paymentWrapperTx', 'transactions', 'txStatusUpdate', 'lrc20Payments', 'contactDetailsUpdate', 'incrementalRestore', ]); // Send screens where an incoming-payment toast would obscure the send UI. Keyed // by the route names registered in navigation/screens.js. const BLOCKED_TOAST_ROUTE_NAMES = new Set([ 'ConfirmPaymentScreen', // sendPaymentScreen.js 'ConfirmSplitPayment', // confirmSplitPayment.js 'StablecoinSendScreen', // stablecoinSendScreen.js ]); function isOnSendScreen() { try { if (!navigationRef.isReady()) return false; return BLOCKED_TOAST_ROUTE_NAMES.has(navigationRef.getCurrentRoute()?.name); } catch { return false; } } // Initiate context const SparkWalletManager = createContext(null); const SparkWalletProvider = ({ children }) => { const { authResetkey } = useAuthContext(); const { masterInfoObject } = useGlobalContextProvider(); const { changeSparkConnectionState, sendWebViewRequest } = useWebView(); const { accountMnemoinc, contactsPrivateKey, publicKey } = useKeysContext(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); const { showToast } = useToastActions(); const { didGetToHomepage, appState, // lastConnectedTimeRef } = useAppStatus(); // const { liquidNodeInformation } = useNodeContext(); const { toggleGlobalContactsInformation, globalContactsInformation } = useGlobalContactsInfo(); const prevAccountMnemoincRef = useRef(null); const contactsPrivateKeyRef = useRef(''); const contactsPublicKeyRef = useRef(null); const [sparkConnectionError, setSparkConnectionError] = useState(null); const isRunningAddListeners = useRef(false); const [sparkInformation, setSparkInformation] = useState({ balance: 0, tokens: {}, transactions: [], identityPubKey: '', sparkAddress: '', didConnect: null, didConnectToFlashnet: null, // Lightweight summary of the local leaves store (full leaf detail lives in // SQLite; the Wallet Leaves page reads it directly). Kept small on purpose — // the full leaf array must never live in context state. leaves: { count: 0, totalValue: 0, lastSyncedAt: 0 }, }); const [tokensImageCache, setTokensImageCache] = useState({}); const depositAddressIntervalRef = useRef(null); const sparkDBaddress = useRef(null); const updatePendingPaymentsIntervalRef = useRef(null); const isInitialLRC20Run = useRef(true); const initialBitcoinIntervalRun = useRef(null); const sparkInfoRef = useRef({ balance: 0, tokens: {}, identityPubKey: '', sparkAddress: '', transactions: [], didConnect: false, }); const sessionTimeRef = useRef(Date.now()); const handledTransfers = useRef(new Set()); const prevListenerType = useRef(null); const prevAppState = useRef(appState); const prevAccountId = useRef(null); const isSendingPaymentRef = useRef(false); const txPollingTimeoutRef = useRef(null); const txPollingAbortControllerRef = useRef(null); const isInitialRender = useRef(true); const authResetKeyRef = useRef(authResetkey); const balanceVersionRef = useRef(0); // One-shot latch: has the post-connect authoritative balance reconcile run for // this session yet? Reset in resetSparkState so an account switch re-arms it. const hasRunInitBalancePoll = useRef(false); const foregroundReconcileAppStateRef = useRef(appState); const txLaneQueueRef = useRef(Promise.resolve()); const uiLaneQueueRef = useRef(Promise.resolve()); const queueDepthRef = useRef(0); const eventSequenceRef = useRef(0); const scrollPositionRef = useRef('total'); // Single-flight guard for the balance reconcile read (see reconcileBalance). // reconcileRunIdRef gives each run ownership so a read that parks across a // background transition can't clear a newer run's lock when it finally settles. const isReconcilingBalanceRef = useRef(false); const reconcileBalanceAgainRef = useRef(false); const reconcileRunIdRef = useRef(0); // Single-flight + throttle for the leaves sync. Leaves change far less often // than balance and syncing them is heavier (map + serialize + bulk insert of a // potentially large set), so we skip a sync that ran within the throttle window. const isReconcilingLeavesRef = useRef(false); const lastLeavesSyncRef = useRef(0); const hydratedLeavesForRef = useRef(null); const LEAVES_SYNC_THROTTLE_MS = 30000; // Single-flight + throttle for the exit-node backfill. Heavier than a leaves // sync (each pending leaf hits Spark operators for its ancestor chain), so it // runs on a longer window and piggybacks leaves syncs rather than firing on // every wallet event. const isReconcilingExitNodesRef = useRef(false); const lastExitNodesSyncRef = useRef(0); // Promise for the exit-node run currently in flight (or null). A forced // refresh (export) awaits this so it can never be silently dropped by the // single-flight guard when a background backfill happens to be running. const exitNodesRunRef = useRef(null); const EXIT_NODES_SYNC_THROTTLE_MS = 60000; // How many pending leaves we fetch ancestors for per operator round-trip. const EXIT_NODES_BATCH_SIZE = 6; // Tracks whether the Spark event stream has dropped, so a later // stream:connected is recognized as a *re*connect (which warrants one // reconcile read) rather than the benign initial connect. const streamWasDisconnectedRef = useRef(false); const homepageTxPreferance = masterInfoObject.homepageTxPreferance; const hideSmallPaymentsHomepage = masterInfoObject.hideSmallPaymentsHomepage; const showTokensInformation = masterInfoObject.enabledBTKNTokens === null ? !!Object.keys(sparkInformation.tokens || {}).filter( token => token !== USDB_TOKEN_ID, ).length : masterInfoObject.enabledBTKNTokens; const handledNavigatedTxs = useRef(new Set()); const [didRunNormalConnection, setDidRunNormalConnection] = useState(false); const [normalConnectionTimeout, setNormalConnectionTimeout] = useState(false); const shouldRunNormalConnection = didRunNormalConnection || normalConnectionTimeout; const currentMnemonicRef = useRef(currentWalletMnemoinc); // Hash of the active main wallet mnemonic. Push events (balance/token/transfer) // are tagged with a walletId (mnemonic hash) so derived gift/pool/savings // wallets sharing the WebView bridge can be told apart from the main wallet. // We ignore any event whose walletId isn't this one. Cached here so we don't // re-hash on every event. const mainWalletHashRef = useRef( currentWalletMnemoinc ? sha256Hash(currentWalletMnemoinc) : null, ); const cleanStatusAndLRC20Intervals = () => { try { for (const intervalId of allIntervalIds) { console.log('Clearing stored interval ID:', intervalId); clearInterval(intervalId); } intervalTracker.clear(); allIntervalIds.clear(); } catch (err) { console.log('Error cleaning lrc20 intervals', err); } }; const clearAllDepositIntervals = () => { console.log( 'Clearing all deposit address intervals. Counts:', depositIntervalIds.size, ); for (const intervalId of depositIntervalIds) { console.log('Clearing deposit interval ID:', intervalId); clearInterval(intervalId); } depositIntervalIds.clear(); console.log('All deposit intervals cleared'); }; useEffect(() => { authResetKeyRef.current = authResetkey; }, [authResetkey]); useEffect(() => { sparkInfoRef.current = { ...sparkInfoRef.current, balance: sparkInformation.balance, tokens: sparkInformation.tokens, identityPubKey: sparkInformation.identityPubKey, sparkAddress: sparkInformation.sparkAddress, didConnect: sparkInformation.didConnect, }; }, [ sparkInformation.balance, sparkInformation.tokens, sparkInformation.identityPubKey, sparkInformation.sparkAddress, sparkInformation.didConnect, ]); useEffect(() => { currentMnemonicRef.current = currentWalletMnemoinc; mainWalletHashRef.current = currentWalletMnemoinc ? sha256Hash(currentWalletMnemoinc) : null; }, [currentWalletMnemoinc]); useEffect(() => { // Fixing race condition with new preloaded txs sessionTimeRef.current = Date.now() + 5 * 1000; }, [currentWalletMnemoinc, authResetkey]); useEffect(() => { if (!didGetToHomepage) return; const timer = setTimeout(() => { setNormalConnectionTimeout(true); }, 20000); return () => clearTimeout(timer); }, [didGetToHomepage]); useEffect(() => { async function handleWalletStateChange() { if (!didGetToHomepage) return; if (!sparkInfoRef.current.identityPubKey) return; if (changeSparkConnectionState.state == null) return; if (!changeSparkConnectionState.state) { setSparkInformation(prev => ({ ...prev, didConnect: false })); } else { let alreadyRanConnection = false; if (!sparkInfoRef.current.identityPubKey && shouldRunNormalConnection) { await resetSparkState(true, false); await connectToSparkWallet(); await initializeFlashnet(currentMnemonicRef.current); alreadyRanConnection = true; } else { setSparkInformation(prev => ({ ...prev, didConnect: !!prev.identityPubKey, })); } const runtime = await selectSparkRuntime( currentMnemonicRef.current, false, undefined, false, ); if (runtime === 'native') { if (!alreadyRanConnection) { await resetSparkState(true, false); await connectToSparkWallet(); await initializeFlashnet(currentMnemonicRef.current); } } } } handleWalletStateChange(); }, [ changeSparkConnectionState, didGetToHomepage, shouldRunNormalConnection, resetSparkState, connectToSparkWallet, ]); useEffect(() => { if (!sparkInfoRef.current?.tokens) return; async function updateTokensImageCache() { const tokenIds = Object.keys(sparkInfoRef.current.tokens); const newCache = await getCachedTokenImages(tokenIds); setTokensImageCache(prev => ({ ...prev, ...newCache })); } updateTokensImageCache(); }, [Object.keys(sparkInformation.tokens || {}).length]); // Debounce refs const debounceTimeoutRef = useRef(null); const debounceMaxWaitRef = useRef(null); const latestIncomingBalanceRef = useRef(null); const pendingTransferIds = useRef(new Set()); // Debounce refs for balance:update — a burst of inbound payments emits one // balance:update each; we coalesce them into a single state write. const balanceDebounceTimeoutRef = useRef(null); const balanceDebounceMaxWaitRef = useRef(null); const latestBalanceRef = useRef(null); // Debounce refs for token-balance:update — same coalescing for token events. const tokenDebounceTimeoutRef = useRef(null); const tokenDebounceMaxWaitRef = useRef(null); const latestTokensRef = useRef(null); const toggleIsSendingPayment = useCallback(isSending => { console.log('Setting is sending payment', isSending); if (isSending) { if (txPollingAbortControllerRef.current) { txPollingAbortControllerRef.current.abort(); txPollingAbortControllerRef.current = null; } } isSendingPaymentRef.current = isSending; }, []); useEffect(() => { if ( !isSendingPayingEventEmiiter.listenerCount(SENDING_PAYMENT_EVENT_NAME) ) { isSendingPayingEventEmiiter.addListener( SENDING_PAYMENT_EVENT_NAME, toggleIsSendingPayment, ); } return () => { console.log('clearning up toggle send pament'); isSendingPayingEventEmiiter.removeListener( SENDING_PAYMENT_EVENT_NAME, toggleIsSendingPayment, ); }; }, [toggleIsSendingPayment]); const debouncedHandleIncomingPayment = useCallback( async balance => { if (pendingTransferIds.current.size === 0) return; const transferIdsToProcess = Array.from(pendingTransferIds.current); pendingTransferIds.current.clear(); console.log( 'Processing debounced incoming payments:', transferIdsToProcess, ); // ─── Step 1: Immediately write placeholders so the restore handler // sees these transfer IDs as already-present in SQLite and skips them. const placeholders = transferIdsToProcess.map(transferId => ({ id: transferId, paymentStatus: 'pending', paymentType: 'unknown', accountId: sparkInfoRef.current.identityPubKey, details: { createdTime: Date.now(), isPlaceholder: true, direction: 'INCOMING', }, })); try { await insertSparkTransactionPlaceholders(placeholders); } catch (error) { console.error('Error writing placeholder transactions:', error); } // ─── Step 2: Fetch tx details in a SINGLE batched call (one WebView // message) instead of one round-trip per transfer. let cachedTransfers = []; try { const idSet = new Set(transferIdsToProcess); const { transfers = [] } = await getSparkTransactions( Math.max(50, transferIdsToProcess.length), undefined, currentMnemonicRef.current, ); cachedTransfers = transfers.filter(transfer => idSet.has(transfer.id)); // Fallback only for ids NOT in the batch window (e.g. an older transfer // that settled while many newer transfers arrived in the same burst). // Normal load hits zero of these, so the single-message goal holds; this // just stops a dropped id from being stuck as a pending placeholder. const foundIds = new Set(cachedTransfers.map(transfer => transfer.id)); const missingIds = transferIdsToProcess.filter(id => !foundIds.has(id)); for (const transferId of missingIds) { const transfer = await getSingleTxDetails( currentMnemonicRef.current, transferId, ); if (transfer) cachedTransfers.push(transfer); } } catch (error) { console.error('Error fetching batched incoming payments:', error); } const paymentObjects = []; const [unpaidInvoices, unpaidContactInvoices] = await Promise.all([ getAllUnpaidSparkLightningInvoices(), getAllSparkContactInvoices(), ]); for (const transferId of transferIdsToProcess) { const tx = cachedTransfers.find(t => t.id === transferId); if (!tx) continue; // Skip UTXO_SWAP handling here — old logic kept if (tx.type === 'UTXO_SWAP') continue; const paymentObj = await transformTxToPaymentObject( tx, sparkInfoRef.current.sparkAddress, undefined, false, unpaidInvoices, sparkInfoRef.current.identityPubKey, 1, undefined, unpaidContactInvoices, currentMnemonicRef.current, ); if (paymentObj) { paymentObjects.push(paymentObj); } } if (!paymentObjects.length) { // Authoritative claim balance; apply upward-only (a claim never reduces // available) and coerce — the webview path delivers it as a string. const claimedBalance = Number(balance); setSparkInformation(prev => Number.isFinite(claimedBalance) && claimedBalance > prev.balance ? { ...prev, balance: claimedBalance } : prev, ); return; } try { await bulkUpdateSparkTransactions( paymentObjects, isSendingPaymentRef.current ? 'transactions' : 'incomingPayment', 0, balance, ); } catch (error) { console.error('bulkUpdateSparkTransactions failed:', error); } }, [sendWebViewRequest], ); 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, limit: homepageTxPreferance, hideSmallPaymentsHomepage, }); setSparkInformation(prev => ({ ...prev, transactions: filtered })); }, [showTokensInformation, homepageTxPreferance, hideSmallPaymentsHomepage], ); 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, limit: homepageTxPreferance, hideSmallPaymentsHomepage, }); if (scrollPositionRef.current !== pos) return; setSparkInformation(prev => ({ ...prev, transactions: filtered })); }, [showTokensInformation, homepageTxPreferance, hideSmallPaymentsHomepage], ); const updateHomepageTxPreferance = useCallback( async (num, smallPaymentOverrides) => { const allTxs = await getAllSparkTransactions({ limit: null, accountId: sparkInfoRef.current.identityPubKey, }); const filtered = filterDisplayableTransactions({ transactions: allTxs, scrollPosition: scrollPositionRef.current, enabledLRC20: showTokensInformation, tokens: sparkInfoRef.current.tokens, limit: num, hideSmallPaymentsHomepage: smallPaymentOverrides ?? hideSmallPaymentsHomepage, }); setSparkInformation(prev => ({ ...prev, transactions: filtered })); }, [showTokensInformation, hideSmallPaymentsHomepage], ); const enqueueTxLane = useCallback((updateType, task) => { queueDepthRef.current += 1; console.log( `[TxLane] +1 (${updateType}) -> depth: ${queueDepthRef.current}`, ); txLaneQueueRef.current = txLaneQueueRef.current .then(task) .catch(err => console.log('[TxLane] task error', updateType, err)) .finally(() => { queueDepthRef.current -= 1; console.log( `[TxLane] -1 (${updateType}) -> depth: ${queueDepthRef.current}`, ); }); return txLaneQueueRef.current; }, []); const enqueueUiLane = useCallback((updateType, task) => { uiLaneQueueRef.current = uiLaneQueueRef.current .then(task) .catch(err => console.log('[UiLane] task error', updateType, err)); return uiLaneQueueRef.current; }, []); const maybeHandleConfirmNavigation = useCallback( async (updateType, txs = null, from) => { try { if (SKIP_CONFIRM_NAV_UPDATE_TYPES.has(updateType)) return; const { identityPubKey } = sparkInfoRef.current; if (!identityPubKey) return; let lastAddedTx; if (txs) { lastAddedTx = txs[0]; } else { [lastAddedTx] = getAllSparkTransactions({ accountId: identityPubKey, limit: 1, }); } if (!lastAddedTx) return; let parsedDetails = {}; try { parsedDetails = JSON.parse(lastAddedTx.details || '{}'); } catch { parsedDetails = {}; } const parsedTx = { ...lastAddedTx, details: parsedDetails, }; const details = parsedTx.details || {}; if (parsedTx.paymentStatus === 'pending') { // Run a tx status check. Will delay toast message // but will prevent a stale pending stae from making the trasnsaction show pending after toast message const { updated } = await updateSparkTxStatus( currentMnemonicRef.current, sparkInfoRef.current.identityPubKey, sendWebViewRequest, true, contactsPrivateKey, publicKey, ); const didUpdateStatus = updated.find( tx => tx.tempId === parsedTx.sparkID && tx.paymentStatus === 'completed', ); if (!didUpdateStatus) { console.log('Payment is pending, show navigation once confimred'); return; } } if (isFlashnetTransfer(parsedTx.sparkID)) { console.log('Failed swap refund, do not show tosat here'); return; } if ( details.senderIdentityPublicKey === process.env.SPARK_IDENTITY_PUBKEY ) { console.log('Refund from Spark, do not show tosat here'); return; } const txTime = new Date(details.time).getTime(); if (Number.isFinite(txTime) && txTime < sessionTimeRef.current) { console.log( 'created before session time was set, skipping confirm tx page navigation', ); return; } if (parsedTx?.paymentStatus?.toLowerCase() === 'failed') { console.log('This payment is of type failed, do not navigate here'); return; } if (details.performSwaptoUSD) { console.log( 'This payment is being used to perform a swap, do not navigate here.', ); return; } if (isSendingPaymentRef.current) { console.log( 'Is sending payment, skipping confirm tx page navigation', ); return; } if (details.direction === 'OUTGOING') { console.log( 'Only incoming payments navigate here, skipping confirm tx page navigation', ); return; } if (details.isHoldInvoice && parsedTx.paymentStatus !== 'completed') { console.log('Blocking unconfirmed hodl invoice from showing'); return; } if (handledNavigatedTxs.current.has(parsedTx.sparkID)) { console.log( 'Already handled transaction, skipping confirm tx page navigation', ); return; } handledNavigatedTxs.current.add(parsedTx.sparkID); if (isOnSendScreen()) { console.log('On a send screen — suppressing incoming payment toast'); return; } // const isOnReceivePage = // navigationRef // .getRootState() // .routes?.filter(item => item.name === 'ReceiveBTC').length === 1; // const hasPaymentTime = !!details.createdTime || !!details.time; // const isNewestPayment = hasPaymentTime // ? new Date(details.createdTime || details.time).getTime() > // newestPaymentTimeRef.current // : false; // let shouldShowConfirm = false; // if ( // (lastAddedTx.paymentType?.toLowerCase() === 'lightning' && // !details.isLNURL && // !details.shouldNavigate && // isOnReceivePage && // isNewestPayment) || // (lastAddedTx.paymentType?.toLowerCase() === 'spark' && // !details.isLRC20Payment && // isOnReceivePage && // isNewestPayment) // ) { // if (lastAddedTx.paymentType?.toLowerCase() === 'spark') { // const unpaidLNInvoices = await getAllUnpaidSparkLightningInvoices(); // const lastMatch = unpaidLNInvoices.findLast(invoice => { // const savedInvoiceDetails = JSON.parse(invoice.details); // return ( // !savedInvoiceDetails.sendingUUID && // !savedInvoiceDetails.isLNURL && // invoice.amount === details.amount // ); // }); // if (lastMatch && !usedSavedTxIds.current.has(lastMatch.id)) { // usedSavedTxIds.current.add(lastMatch.id); // const lastInvoiceDetails = JSON.parse(lastMatch.details); // if (details.time - lastInvoiceDetails.createdTime < 60 * 1000) { // shouldShowConfirm = true; // } // } // } else { // shouldShowConfirm = true; // } // } showToast({ amount: details.amount, LRC20Token: details.LRC20Token, isLRC20Payment: !!details.LRC20Token, duration: 7000, isSARPayment: !!details.isSARIncoming, type: 'confirmTx', }); } catch (err) { console.log('[UiLane] confirm navigation error', err); } }, [showToast], ); const projectTransactionsForEvent = useCallback( async event => { const { identityPubKey } = sparkInfoRef.current; if (!identityPubKey) { console.warn( 'Skipping tx projection because identityPubKey is not ready yet', ); return; } const txs = await getAllSparkTransactions({ limit: null, accountId: identityPubKey, }); filterAndSetTransactions(txs); enqueueUiLane(event.updateType, () => maybeHandleConfirmNavigation( event.updateType, txs, 'project transactions for event', ), ); }, [enqueueUiLane, maybeHandleConfirmNavigation, filterAndSetTransactions], ); // Applies the most recent balance:update value immediately, cancelling the // debounce. Used as the balance:update debounce flush AND to sync the // displayed balance with the incoming-payment toast: balance:update fires // before transfer:claimed, so the new value is already staged in // latestBalanceRef and we flush it in the same pass the toast is shown. const flushBalanceNow = useCallback(() => { if (balanceDebounceTimeoutRef.current) { clearTimeout(balanceDebounceTimeoutRef.current); balanceDebounceTimeoutRef.current = null; } if (balanceDebounceMaxWaitRef.current) { clearTimeout(balanceDebounceMaxWaitRef.current); balanceDebounceMaxWaitRef.current = null; } const nextBalance = latestBalanceRef.current; if (!Number.isFinite(nextBalance)) return; if (nextBalance === sparkInfoRef.current.balance) return; const commit = value => { // Ordering guard shared with reconcileBalance so a slow reconcile read // can't overwrite this newer event value. const myVersion = ++balanceVersionRef.current; const { identityPubKey } = sparkInfoRef.current; saveAccountBalanceSnapshot( identityPubKey, value, sparkInfoRef.current.tokens, ); reconcileLeaves(); setSparkInformation(prev => { if (myVersion < balanceVersionRef.current) return prev; if (prev.balance === value) return prev; return { ...prev, balance: value }; }); }; // A decrease can be a real spend OR a transient dip while the SDK optimizes // leaves (available drops below owned mid-swap, then settles back). Don't // commit an optimization dip — hold the displayed balance; the optimization's // settle event re-arms a flush at the true value, and the reconcile safety // net backstops it. Skip the check during a send: that decrease is real and // must land. Runs at most once per debounce window (not per event), so it // can't storm the WebView rate limiter. if ( nextBalance < sparkInfoRef.current.balance && !isSendingPaymentRef.current ) { isOptimizationInProgress({ mnemonic: currentMnemonicRef.current }) .then(res => { if (res?.isOptimizing) return; // hold — dip is an optimization artifact // A newer event superseded this value while we awaited; its own flush // handles it. if (latestBalanceRef.current !== nextBalance) return; if (nextBalance === sparkInfoRef.current.balance) return; commit(nextBalance); }) // ponytail: on check failure land the decrease rather than strand a real // spend; the 10s WebView-bridge timeout caps the wait and reconcile // corrects any rare false drop. .catch(() => { if (latestBalanceRef.current === nextBalance) commit(nextBalance); }); return; } commit(nextBalance); }, [reconcileLeaves]); // One authoritative balance read, applied directly. The displayed balance is // driven in real time by balance:update events; this read is a safety net to // recover a balance whose event was missed (backgrounded / stream drop) and // to land post-restore deposit claims. Single-flight: a request while a read // is in flight sets a re-run flag instead of stacking reads. The version // guard makes a live balance:update win over a slower reconcile read. const reconcileBalance = useCallback(async () => { const mnemonic = currentMnemonicRef.current; if (!mnemonic) return false; if (isReconcilingBalanceRef.current) { reconcileBalanceAgainRef.current = true; return false; } isReconcilingBalanceRef.current = true; const runId = ++reconcileRunIdRef.current; // Whether this run landed an authoritative (finite) balance read. Returned // so callers like the post-connect timeout retry know when to stop. let didApplyFinite = false; try { do { reconcileBalanceAgainRef.current = false; const myVersion = ++balanceVersionRef.current; const result = await getBalanceWithTimeout(mnemonic); // A background transition (or account switch) invalidates this run; the // foreground effect bumps reconcileRunIdRef so a parked read can't apply // a stale value or clear a newer run's lock. if (runId !== reconcileRunIdRef.current) return didApplyFinite; if (mnemonic !== currentMnemonicRef.current) return didApplyFinite; if (AppState.currentState !== 'active') return didApplyFinite; const numericBalance = Number(result?.balance); if (Number.isFinite(numericBalance)) didApplyFinite = true; const { identityPubKey } = sparkInfoRef.current; saveAccountBalanceSnapshot( identityPubKey, Number.isFinite(numericBalance) ? numericBalance : sparkInfoRef.current.balance, result?.didWork ? result.tokensObj : sparkInfoRef.current.tokens, ); setSparkInformation(prev => { if (myVersion < balanceVersionRef.current) return prev; return { ...prev, balance: Number.isFinite(numericBalance) ? numericBalance : prev.balance, tokens: result?.didWork ? result.tokensObj : prev.tokens, }; }); } while ( reconcileBalanceAgainRef.current && runId === reconcileRunIdRef.current && mnemonic === currentMnemonicRef.current ); } catch (err) { console.log('[reconcileBalance] error', err); } finally { if (runId === reconcileRunIdRef.current) { isReconcilingBalanceRef.current = false; } } return didApplyFinite; }, []); // After a cold connect where the init balance read timed out, the stale // snapshot is on screen and the connect-time balance:update was missed (the // listeners attach only after connect). Retry a bounded, backing-off reconcile // so a payment received while backgrounded still lands, independent of whether // the restore poller surfaces a tx delta. Stops on the first finite read. const retryBalanceAfterTimeout = useCallback(async () => { const mnemonic = currentMnemonicRef.current; const delays = [0, 3000, 6000]; for (const delay of delays) { await new Promise(res => setTimeout(res, delay)); if (mnemonic !== currentMnemonicRef.current) return; if (AppState.currentState !== 'active') return; // Don't read a balance while leaves are locked for a send — it would read // the transient 0. The send's own reconcile lands the settled value. if (isSendingPaymentRef.current) continue; const didApply = await reconcileBalance(); if (didApply) return; } }, [reconcileBalance]); // Refreshes the local leaves store from a live getLeaves() snapshot, then // updates the small in-context summary. Single-flight + throttled; the heavy // map/serialize/insert work is deferred behind InteractionManager so it never // competes with navigation or gestures. The fetch uses the fast coordinator // path (isBalanceCheck=true) — the export flow forces a full getLeaves(false). // Incrementally caches each exit-eligible leaf's ancestor TreeNodes so the // local export bundle can drive an OFFLINE multi-level unilateral exit. Driven // purely by DB `pending` flags, so it is automatically resumable: any leaf left // pending (fetch failed/timed out, app backgrounded, account switched, or leaf // missing from this snapshot) is retried on a later reconcile. Single-flight + // throttled (longer window than leaves). Scoped to the captured account, so an // account switch mid-run can only no-op against the old account. const reconcileExitNodes = useCallback(async (rawLeaves, force = false) => { const identityPubKey = sparkInfoRef.current.identityPubKey; const mnemonic = currentMnemonicRef.current; if (!identityPubKey || !mnemonic) return; if (!Array.isArray(rawLeaves)) return; if (isReconcilingExitNodesRef.current) { // A run is already in flight. Background calls stay single-flight and are // dropped. A forced refresh (export) must not be silently skipped: wait // for the in-flight run to finish, then run our forced pass so the export // bundle is built from freshly fetched ancestor chains. if (!force) return; try { await exitNodesRunRef.current; } catch {} } if ( !force && Date.now() - lastExitNodesSyncRef.current < EXIT_NODES_SYNC_THROTTLE_MS ) return; const run = (async () => { isReconcilingExitNodesRef.current = true; try { const leafById = new Map( rawLeaves.filter(leaf => leaf?.id).map(leaf => [leaf.id, leaf]), ); let pendingIds; while (true) { if (AppState.currentState !== 'active') break; if (mnemonic !== currentMnemonicRef.current) break; let batchLeaves; if (force) { // Process the provided leaves directly once. batchLeaves = rawLeaves; } else { pendingIds = await getPendingExitNodeLeafIds( identityPubKey, EXIT_NODES_BATCH_SIZE, ); if (!pendingIds.length) break; batchLeaves = []; for (const id of pendingIds) { const leaf = leafById.get(id); if (leaf) batchLeaves.push(leaf); } if (!batchLeaves.length) break; } const exitNodesMap = await getSparkExitNodesForLeavesWithTimeout( mnemonic, batchLeaves, ); if (mnemonic !== currentMnemonicRef.current) break; // Persist every leaf the fetch succeeded on (present in the map). // Leaves absent from the map stay pending and are retried later. let markedThisBatch = 0; for (const leaf of batchLeaves) { if (!(leaf.id in exitNodesMap)) continue; const didMark = await saveExitNodesForLeaf( identityPubKey, leaf.id, exitNodesMap[leaf.id], ); if (didMark) markedThisBatch++; } if (force) { // Only process the supplied leaves once. break; } if (markedThisBatch === 0) break; // Yield between batches so the operator round-trips never starve frames. await new Promise(resolve => setTimeout(resolve, 0)); } if (!force && rawLeaves?.length > 0 && pendingIds?.length) lastExitNodesSyncRef.current = Date.now(); // Reflect backfill progress into the in-context summary for the UI. const progress = await getExitNodeSyncProgress(identityPubKey); if (identityPubKey === sparkInfoRef.current.identityPubKey) { setSparkInformation(prev => ({ ...prev, leaves: { ...(prev.leaves || {}), exitNodesPending: progress.pending, exitNodesComplete: progress.complete, }, })); } } catch (err) { console.log('[reconcileExitNodes] error', err); } finally { isReconcilingExitNodesRef.current = false; } })(); exitNodesRunRef.current = run; return run; }, []); const reconcileLeaves = useCallback( async (force = false) => { const mnemonic = currentMnemonicRef.current; if (!mnemonic) return; const identityPubKey = sparkInfoRef.current.identityPubKey; if (!identityPubKey) return; if (isReconcilingLeavesRef.current) return; if ( !force && Date.now() - lastLeavesSyncRef.current < LEAVES_SYNC_THROTTLE_MS ) return; isReconcilingLeavesRef.current = true; try { const rawLeaves = await getSparkLeavesWithTimeout(mnemonic, true); // A null result means the read timed out / errored — keep store as-is. if (!Array.isArray(rawLeaves)) return; if (mnemonic !== currentMnemonicRef.current) return; await new Promise((resolve, reject) => { InteractionManager.runAfterInteractions(async () => { try { await replaceAllLeaves(identityPubKey, rawLeaves); const stats = await getGlobalLeafStats(identityPubKey); setSparkInformation(prev => ({ ...prev, leaves: { ...(prev.leaves || {}), count: stats.totalLeaves, totalValue: stats.totalValue, lastSyncedAt: stats.lastSyncedAt, }, })); resolve(); } catch (err) { reject(err); } }); }); if (rawLeaves?.length) lastLeavesSyncRef.current = Date.now(); // Backfill the exit-node ancestors for this fresh snapshot (background, // not awaited — it self-throttles and single-flights). reconcileExitNodes(rawLeaves); } catch (err) { console.log('[reconcileLeaves] error', err); } finally { isReconcilingLeavesRef.current = false; } }, [reconcileExitNodes], ); const handleUpdate = useCallback( (...args) => { const [updateType = 'transactions'] = args; const event = { seq: ++eventSequenceRef.current, updateType, }; // Balance is driven in real time by balance:update / token-balance:update. // These update types mark a balance-changing DB action (restore // completion, deposit claim, send wrapper); we fire one reconcile read as // a safety net in case the matching event was missed. if (BALANCE_INTENT_UPDATE_TYPES.has(updateType)) { reconcileBalance(); // Leaves changed alongside the balance — refresh the local store too // (throttled, so a burst of intents won't trigger repeated full syncs). reconcileLeaves(); } // Apply the displayed balance in the same pass as the incoming toast. // balance:update fires before transfer:claimed, so the new value is // already staged in latestBalanceRef — flush it now so the number ticks // up exactly when the "received" toast appears. if (updateType === 'incomingPayment') { // Authoritative post-claim balance from transfer:claimed (args[2]). // While the SDK optimizes leaves after a claim, balance:update reports a // suppressed `available`, so flushBalanceNow holds the pre-claim number. // Apply the claim snapshot in that window so the balance ticks up with // the toast. Upward-only + optimization-gated: a normal send cancels // optimization, so its decrease is never masked by a stale higher // snapshot (no over-send). The balanceVersionRef guard in // flushBalanceNow lets a newer balance:update win. const claimedBalance = Number(args[2]); if ( Number.isFinite(claimedBalance) && claimedBalance > sparkInfoRef.current.balance ) { isOptimizationInProgress({ mnemonic: currentMnemonicRef.current }) .then(res => { if ( res?.isOptimizing && claimedBalance > sparkInfoRef.current.balance ) { // Stage above any pending balance:update value, then commit // through the shared version-guarded path. if (claimedBalance > (latestBalanceRef.current ?? 0)) { latestBalanceRef.current = claimedBalance; } flushBalanceNow(); } }) .catch(() => {}); // reconcile safety-net backstops a failed check } flushBalanceNow(); } if (!TX_REFRESH_UPDATE_TYPES.has(updateType)) { return Promise.resolve(); } return enqueueTxLane(updateType, () => projectTransactionsForEvent(event), ); }, [ enqueueTxLane, projectTransactionsForEvent, reconcileBalance, reconcileLeaves, flushBalanceNow, ], ); const transferHandler = useCallback((transferId, balance, walletId) => { // Ignore events from derived wallets (gift/pool/savings). Undefined walletId // = pre-tagging bundle → treat as main wallet (backward compatible). if (walletId && walletId !== mainWalletHashRef.current) return; if (handledTransfers.current.has(transferId)) return; handledTransfers.current.add(transferId); console.log(`Transfer ${transferId} claimed. New balance: ${balance}`); // Add transferId to pending set pendingTransferIds.current.add(transferId); // Always flush with the most recent balance, even when the max-wait timer // (set on the first event of the burst) fires. latestIncomingBalanceRef.current = balance; const flush = () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); debounceTimeoutRef.current = null; } if (debounceMaxWaitRef.current) { clearTimeout(debounceMaxWaitRef.current); debounceMaxWaitRef.current = null; } debouncedHandleIncomingPayment(latestIncomingBalanceRef.current); }; // Trailing debounce: flush 500ms after the last event… if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current); debounceTimeoutRef.current = setTimeout(flush, 500); // …but cap the wait at 10s so a sustained burst (events arriving faster // than every 500ms, which would perpetually reset the trailing timer and // never flush) still applies the balance periodically. if (!debounceMaxWaitRef.current) { debounceMaxWaitRef.current = setTimeout(flush, 10000); } }, []); // Authoritative writer for the displayed sats balance. balance:update fires on // every balance change (deposits, transfers, swaps, claims) with the real // current { available, owned, incoming }; we display `available` (parity with // the SDK's deprecated `balance` field). This is the single fast path that // makes sends/swaps/deposits reflect immediately — previously only inbound // claims had a push event and everything else waited on the poller. const balanceUpdateHandler = useCallback( (snapshot, walletId) => { // Ignore events from derived wallets (gift/pool/savings). Undefined // walletId = pre-tagging bundle → treat as main wallet (backward compatible). if (walletId && walletId !== mainWalletHashRef.current) return; const available = Number(snapshot?.available); console.log('hanlding balance update before send block', available); // blocking send screen changes to not affect payments if (isOnSendScreen() && available <= sparkInfoRef.current.balance) return; if (!Number.isFinite(available)) return; // Value-gate: ignore no-op events so a burst of inbound transfers (each // emitting balance:update) can't trigger a render / DB-write storm. When a // flush is still pending, compare against the last STAGED value // (latestBalanceRef) rather than the committed balance. sparkInfoRef.balance // lags committed state by a render/effect cycle and holds the pre-burst // value mid-debounce, so comparing against it would drop a legitimate // return-to-baseline event (X→Y→X within the debounce) and leave the stale // intermediate Y staged to flush — an overstated balance / over-send risk. const hasPendingFlush = balanceDebounceTimeoutRef.current !== null || balanceDebounceMaxWaitRef.current !== null; const currentTarget = hasPendingFlush ? latestBalanceRef.current : sparkInfoRef.current.balance; if (available === currentTarget) return; // Always flush with the most recent value, even when the max-wait timer // (set on the first event of the burst) fires. latestBalanceRef.current = available; // Trailing debounce: flush 3s after the last event… if (balanceDebounceTimeoutRef.current) clearTimeout(balanceDebounceTimeoutRef.current); balanceDebounceTimeoutRef.current = setTimeout(flushBalanceNow, 3000); // …but cap the wait at 10s so a sustained burst (events arriving faster // than every 3s, which would perpetually reset the trailing timer and // never flush) still applies the balance periodically. if (!balanceDebounceMaxWaitRef.current) { balanceDebounceMaxWaitRef.current = setTimeout(flushBalanceNow, 10000); } }, [flushBalanceNow], ); // token-balance:update fires when a token tx finalizes and carries the full // current token-balance map (getTokenBalanceMap() in the SDK). We merge that // payload straight into the cache instead of issuing another getSparkBalance // round-trip — same result, one fewer WebView read per event. The WebView // runtime delivers the already-normalized map; the native runtime delivers the // raw SDK Map and is normalized at registration (see addListeners). // Token analog of debouncedHandleIncomingPayment / reconcileBalance: builds token // (LRC20) transaction history. Driven by token-balance:update events, a one-time // startup fetch, and the reconnect/foreground reconcile — replacing the old 10s poll. const reconcileTokenTransactions = useCallback((isInitialRun = false) => { if (isSendingPaymentRef.current) return; const mnemonic = currentMnemonicRef.current; if (!mnemonic) return; getLRC20Transactions({ ownerPublicKeys: [sparkInfoRef.current.identityPubKey], sparkAddress: sparkInfoRef.current.sparkAddress, isInitialRun, mnemonic, }); }, []); const tokenBalanceUpdateHandler = useCallback( (tokensObject, walletId) => { // Ignore events from derived wallets (gift/pool/savings). Undefined walletId // = pre-tagging bundle → treat as main wallet (backward compatible). if (walletId && walletId !== mainWalletHashRef.current) return; // Each event carries the full current token-balance map, so only the latest // payload matters during a burst. latestTokensRef.current = tokensObject ?? {}; const flush = async () => { if (tokenDebounceTimeoutRef.current) { clearTimeout(tokenDebounceTimeoutRef.current); tokenDebounceTimeoutRef.current = null; } if (tokenDebounceMaxWaitRef.current) { clearTimeout(tokenDebounceMaxWaitRef.current); tokenDebounceMaxWaitRef.current = null; } const mnemonic = currentMnemonicRef.current; if (!mnemonic) return; const merged = await mergeAndCacheTokens( latestTokensRef.current, mnemonic, ); if (mnemonic !== currentMnemonicRef.current) return; setSparkInformation(prev => ({ ...prev, tokens: merged })); // Persist tokens so a token-only change survives a cold start, matching // flushBalanceNow / reconcileBalance. saveAccountBalanceSnapshot( sparkInfoRef.current.identityPubKey, sparkInfoRef.current.balance, merged, ); // A token balance change means a token tx finalized — fetch its history now reconcileTokenTransactions(false); }; // Trailing debounce 500ms after the last event, capped at 10s so a // sustained burst still flushes periodically (see balanceUpdateHandler). if (tokenDebounceTimeoutRef.current) clearTimeout(tokenDebounceTimeoutRef.current); tokenDebounceTimeoutRef.current = setTimeout(flush, 3000); if (!tokenDebounceMaxWaitRef.current) { tokenDebounceMaxWaitRef.current = setTimeout(flush, 10000); } }, [reconcileTokenTransactions], ); // Stream lifecycle. A connect after a drop means events may have been missed // while the stream was down, so fire one reconcile read. The initial connect // is benign and skipped. const streamStatusHandler = useCallback( status => { if (status === 'disconnected' || status === 'reconnecting') { streamWasDisconnectedRef.current = true; return; } if (status !== 'connected') return; if (!streamWasDisconnectedRef.current) return; streamWasDisconnectedRef.current = false; if (AppState.currentState !== 'active') return; if (!sparkInfoRef.current.didConnect) return; // Skip the balance reconcile while a send is in flight — a mid-send read // returns the locked 0/partial; the send's paymentWrapperTx reconcile // lands the settled balance at settlement. if (!isSendingPaymentRef.current) reconcileBalance(); // Events (including leaf changes) may have been missed while the stream // was down — refresh the local leaves store too. reconcileLeaves(); // Recover token txs whose token-balance:update fired while the stream was down. reconcileTokenTransactions(false); }, [reconcileBalance, reconcileLeaves, reconcileTokenTransactions], ); useEffect(() => { if (!sparkInformation.identityPubKey) { console.log('Skipping listener setup - no identity pub key yet'); return; } console.log('adding web view listeners'); sparkTransactionsEventEmitter.on(SPARK_TX_UPDATE_ENVENT_NAME, handleUpdate); incomingSparkTransaction.on(INCOMING_SPARK_TX_NAME, transferHandler); sparkBalanceUpdateEmitter.on( BALANCE_UPDATE_EVENT_NAME, balanceUpdateHandler, ); sparkTokenBalanceUpdateEmitter.on( TOKEN_BALANCE_UPDATE_EVENT_NAME, tokenBalanceUpdateHandler, ); sparkStreamStatusEmitter.on(STREAM_STATUS_EVENT_NAME, streamStatusHandler); return () => { console.log('Cleaning up spark event listeners'); sparkTransactionsEventEmitter.removeListener( SPARK_TX_UPDATE_ENVENT_NAME, handleUpdate, ); incomingSparkTransaction.removeListener( INCOMING_SPARK_TX_NAME, transferHandler, ); sparkBalanceUpdateEmitter.removeListener( BALANCE_UPDATE_EVENT_NAME, balanceUpdateHandler, ); sparkTokenBalanceUpdateEmitter.removeListener( TOKEN_BALANCE_UPDATE_EVENT_NAME, tokenBalanceUpdateHandler, ); sparkStreamStatusEmitter.removeListener( STREAM_STATUS_EVENT_NAME, streamStatusHandler, ); }; }, [ sparkInformation.identityPubKey, handleUpdate, transferHandler, balanceUpdateHandler, tokenBalanceUpdateHandler, streamStatusHandler, ]); const addListeners = async mode => { console.log('Adding Spark listeners...'); if (AppState.currentState !== 'active') return false; const walletHash = sha256Hash(currentMnemonicRef.current); if (listenerLock.get(walletHash)) { console.log('addListeners already running for this wallet, skippingdh'); // Another run owns the attach — don't report it as a failure. return true; } listenerLock.set(walletHash, true); let attached = false; try { const runtime = await selectSparkRuntime(currentMnemonicRef.current); if (mode === 'full') { if (runtime === 'native') { const nativeWallet = sparkWallet[walletHash]; if (nativeWallet) { // This native wallet is the main wallet — tag its events with // walletHash so the handlers' walletId guard passes. if (!nativeWallet.listenerCount('transfer:claimed')) { nativeWallet.on('transfer:claimed', (transferId, balance) => transferHandler(transferId, balance, walletHash), ); } if (!nativeWallet.listenerCount('balance:update')) { nativeWallet.on('balance:update', balance => balanceUpdateHandler(balance, walletHash), ); } if (!nativeWallet.listenerCount('token-balance:update')) { // Native delivers the raw SDK event ({ tokenBalances: Map }); // normalize it to the same token map the WebView runtime posts so // tokenBalanceUpdateHandler can stay runtime-agnostic. nativeWallet.on('token-balance:update', event => { const tokensObject = {}; for (const [id, data] of event?.tokenBalances ?? []) { tokensObject[id] = { ...data, balance: data.availableToSendBalance, }; } tokenBalanceUpdateHandler(tokensObject, walletHash); }); } if (!nativeWallet.listenerCount('stream:connected')) { nativeWallet.on('stream:connected', () => streamStatusHandler('connected'), ); } if (!nativeWallet.listenerCount('stream:disconnected')) { nativeWallet.on('stream:disconnected', () => streamStatusHandler('disconnected'), ); } if (!nativeWallet.listenerCount('stream:reconnecting')) { nativeWallet.on('stream:reconnecting', () => streamStatusHandler('reconnecting'), ); } } attached = !!nativeWallet; } else { const mnemonic = currentMnemonicRef.current; attached = await attachWalletListeners( mnemonic, () => mnemonic !== currentMnemonicRef.current, ); } // Single restore path for every connect (initial + subsequent). The // poller writes txs via bulkUpdateSparkTransactions, whose SPARK_TX // update event drives the UI/balance refresh; isRestoringState guards // against overlap. if (txPollingAbortControllerRef.current) { txPollingAbortControllerRef.current.abort(); } txPollingAbortControllerRef.current = new AbortController(); const restorePoller = createRestorePoller( currentMnemonicRef.current, isSendingPaymentRef.current, currentMnemonicRef, txPollingAbortControllerRef.current, result => { console.log('RESTORE COMPLETE'); }, sparkInfoRef.current, sendWebViewRequest, ); restorePoller.start(); updateSparkTxStatus( currentMnemonicRef.current, sparkInfoRef.current.identityPubKey, sendWebViewRequest, false, contactsPrivateKey, publicKey, ); // One-time startup token history fetch (token analog of // restorePoller.start()) — catches token txs received while the app was // closed. Live updates thereafter come from token-balance:update. reconcileTokenTransactions(isInitialLRC20Run.current); if (isInitialLRC20Run.current) isInitialLRC20Run.current = false; if (updatePendingPaymentsIntervalRef.current) { console.log('BLOCKING TRYING TO SET INTERVAL AGAIN'); clearInterval(updatePendingPaymentsIntervalRef.current); updatePendingPaymentsIntervalRef.current = null; } const capturedAuthKey = authResetKeyRef.current; const capturedMnemonic = currentMnemonicRef.current; const capturedWalletHash = walletHash; const intervalId = setInterval(async () => { try { if (capturedAuthKey !== authResetKeyRef.current) { console.log('Auth key changed. Aborting interval.'); clearInterval(intervalId); intervalTracker.delete(capturedWalletHash); allIntervalIds.delete(intervalId); return; } if (capturedMnemonic !== currentMnemonicRef.current) { console.log('Mnemonic changed. Aborting interval.'); clearInterval(intervalId); intervalTracker.delete(capturedWalletHash); allIntervalIds.delete(intervalId); return; } if (AppState.currentState !== 'active') { console.log('App not active. Skipping interval.'); return; } const response = await updateSparkTxStatus( currentMnemonicRef.current, sparkInfoRef.current.identityPubKey, sendWebViewRequest, false, contactsPrivateKey, publicKey, ); if (response.shouldCheck) { // No pending txs listed const txs = sparkInfoRef.current.transactions; // if we find a pending tx that means the db and spark state are unaligned const isStateUnalighed = txs?.find( tx => tx.paymentStatus === 'pending', ); if (isStateUnalighed) { // send message to update the state with the correct txs sparkTransactionsEventEmitter.emit( SPARK_TX_UPDATE_ENVENT_NAME, 'transactions', ); } } // await checkHodlInvoicePaymentStatuses( // currentMnemonicRef.current, // sparkInfoRef.current.identityPubKey, // ); } catch (err) { console.error('Error during periodic restore:', err); } }, 10 * 1000); updatePendingPaymentsIntervalRef.current = intervalId; intervalTracker.set(walletHash, intervalId); allIntervalIds.add(intervalId); } } catch (error) { console.error('Error in addListeners:', error); } finally { listenerLock.set(walletHash, false); console.log('Lock released for wallet:', walletHash); } return attached; }; const removeListeners = async (onlyClearIntervals = false) => { console.log('Removing spark listeners'); cleanStatusAndLRC20Intervals(); if (!onlyClearIntervals) { const runtime = await selectSparkRuntime(currentMnemonicRef.current); if (!prevAccountMnemoincRef.current) { prevAccountMnemoincRef.current = currentMnemonicRef.current; return; } const hashedMnemonic = sha256Hash(prevAccountMnemoincRef.current); if (runtime === 'native') { const nativeWallet = sparkWallet[hashedMnemonic]; if (prevAccountMnemoincRef.current && nativeWallet) { if (nativeWallet.listenerCount('transfer:claimed')) { nativeWallet.removeAllListeners('transfer:claimed'); } if (nativeWallet.listenerCount('balance:update')) { nativeWallet.removeAllListeners('balance:update'); } if (nativeWallet.listenerCount('token-balance:update')) { nativeWallet.removeAllListeners('token-balance:update'); } if (nativeWallet.listenerCount('stream:connected')) { nativeWallet.removeAllListeners('stream:connected'); } if (nativeWallet.listenerCount('stream:disconnected')) { nativeWallet.removeAllListeners('stream:disconnected'); } if (nativeWallet.listenerCount('stream:reconnecting')) { nativeWallet.removeAllListeners('stream:reconnecting'); } } } else { const response = await sendWebViewRequestGlobal( OPERATION_TYPES.removeListeners, { mnemonic: prevAccountMnemoincRef.current }, ); // Benign: handlers are walletId-guarded and re-adding is idempotent. if (!response?.didWork) { console.log('removeWalletEventListener failed', response?.error); } } prevAccountMnemoincRef.current = currentMnemonicRef.current; } // Clear debounce timeout when removing listeners if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); debounceTimeoutRef.current = null; } if (debounceMaxWaitRef.current) { clearTimeout(debounceMaxWaitRef.current); debounceMaxWaitRef.current = null; } if (balanceDebounceTimeoutRef.current) { clearTimeout(balanceDebounceTimeoutRef.current); balanceDebounceTimeoutRef.current = null; } if (balanceDebounceMaxWaitRef.current) { clearTimeout(balanceDebounceMaxWaitRef.current); balanceDebounceMaxWaitRef.current = null; } if (tokenDebounceTimeoutRef.current) { clearTimeout(tokenDebounceTimeoutRef.current); tokenDebounceTimeoutRef.current = null; } if (tokenDebounceMaxWaitRef.current) { clearTimeout(tokenDebounceMaxWaitRef.current); tokenDebounceMaxWaitRef.current = null; } // Clear pending transfer IDs pendingTransferIds.current.clear(); // Clear update payment state timer if (updatePendingPaymentsIntervalRef.current) { clearInterval(updatePendingPaymentsIntervalRef.current); updatePendingPaymentsIntervalRef.current = null; } if (txPollingTimeoutRef.current) { clearTimeout(txPollingTimeoutRef.current); txPollingTimeoutRef.current = null; } if (txPollingAbortControllerRef.current) { txPollingAbortControllerRef.current.abort(); txPollingAbortControllerRef.current = null; } }; const resetSparkState = useCallback( async (internalRefresh = false, shouldClearMnemonicCache = true) => { // Reset refs to initial values await removeListeners(true); if (shouldClearMnemonicCache) { clearMnemonicCache(); } prevAccountMnemoincRef.current = null; isRunningAddListeners.current = false; if (depositAddressIntervalRef.current) { clearInterval(depositAddressIntervalRef.current); } initialBitcoinIntervalRun.current = null; depositAddressIntervalRef.current = null; sparkInfoRef.current = { balance: 0, tokens: {}, identityPubKey: '', sparkAddress: '', transactions: [], didConnect: false, }; handledTransfers.current = new Set(); streamWasDisconnectedRef.current = false; prevListenerType.current = null; prevAppState.current = 'active'; prevAccountId.current = null; isSendingPaymentRef.current = false; txPollingAbortControllerRef.current = null; txPollingTimeoutRef.current = null; balanceVersionRef.current = 0; hasRunInitBalancePoll.current = false; txLaneQueueRef.current = Promise.resolve(); uiLaneQueueRef.current = Promise.resolve(); queueDepthRef.current = 0; eventSequenceRef.current = 0; // Invalidate any in-flight reconcile read and release the single-flight // lock so the next session starts clean. reconcileRunIdRef.current += 1; isReconcilingBalanceRef.current = false; reconcileBalanceAgainRef.current = false; // Reset state variables setSparkConnectionError(null); setSparkInformation({ balance: 0, tokens: {}, transactions: [], identityPubKey: '', sparkAddress: '', didConnect: null, didConnectToFlashnet: null, }); contactsPrivateKeyRef.current = ''; contactsPublicKeyRef.current = null; clearSpendAndReplaceCorrelationMemo(); if (!internalRefresh) { setDidRunNormalConnection(false); setNormalConnectionTimeout(false); } }, [], ); useEffect(() => { if (isInitialRender.current) { isInitialRender.current = false; return; } resetSparkState(); }, [authResetkey]); useEffect(() => { contactsPrivateKeyRef.current = contactsPrivateKey; contactsPublicKeyRef.current = publicKey; }, [contactsPrivateKey, publicKey]); // Register a stable getter so transaction writes can snapshot the centralized // auth keys without storing key material in module scope or changing every // bulkUpdateSparkTransactions call site. useEffect(() => { setSpendAndReplaceAuthGetter(() => ({ privateKey: contactsPrivateKeyRef.current, publicKey: contactsPublicKeyRef.current, })); return () => setSpendAndReplaceAuthGetter(null); }, []); // Add event listeners to listen for bitcoin and lightning or spark transfers when receiving only when screen is active useEffect(() => { // Handle immediate background transitions synchronously(background events on android were not running) if (prevAppState.current !== appState && appState === 'background') { console.log('App moved to background — clearing listener type'); prevListenerType.current = null; } const timeoutId = setTimeout(async () => { if (!didGetToHomepage) return; if (!sparkInfoRef.current.identityPubKey) return; const getListenerType = () => { if (appState === 'active') return 'full'; return null; }; const newType = getListenerType(); const prevType = prevListenerType.current; const prevId = prevAccountId.current; // Only reconfigure listeners when becoming active if ( (newType !== prevType || prevId !== sparkInfoRef.current.identityPubKey) && appState === 'active' ) { await removeListeners(false); // Leave prevListenerType null if the attach failed so the next // foreground/account change re-attempts instead of assuming listeners // are live. const attached = newType ? await addListeners(newType) : true; prevListenerType.current = attached ? newType : null; prevAccountId.current = sparkInfoRef.current.identityPubKey; } prevAppState.current = appState; }, 500); return () => clearTimeout(timeoutId); }, [ appState, sparkInformation.didConnect, sparkInformation.identityPubKey, didGetToHomepage, // isSendingPayment, sendWebViewRequest, ]); useEffect(() => { if (!didGetToHomepage) return; if (!sparkInformation.didConnect) return; if (!sparkInformation.identityPubKey) return; // Interval to check deposit addresses to see if they were paid const handleDepositAddressCheck = async () => { try { console.log('l1Deposit check running....'); if (AppState.currentState !== 'active') return; if (isSendingPaymentRef.current) return; if (!currentMnemonicRef.current) return; 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, ); for (const address of depositAddresses) { if (!address) continue; const [exploraData, unclaimedUtxos, allUtxos] = await Promise.all([ getDepositAddressTxIds(address, contactsPrivateKey, publicKey), getUtxosForDepositAddress({ depositAddress: address, mnemonic: currentMnemonicRef.current, excludeClaimed: true, }), getUtxosForDepositAddress({ depositAddress: address, mnemonic: currentMnemonicRef.current, excludeClaimed: false, }), ]); const claimableByTxid = new Set( unclaimedUtxos.didWork ? unclaimedUtxos.utxos.map(u => u.txid) : [], ); const allKnownByTxid = new Set( allUtxos.didWork ? allUtxos.utxos.map(u => u.txid) : [], ); 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 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):', tx.txid, { isConfirmed: tx.isConfirmed, }, ); await addPendingTransaction( { transactionId: tx.txid, creditAmountSats: tx.amount, }, address, sparkInfoRef.current, ); savedTxCache.set(tx.txid, { sparkID: tx.txid, accountId: sparkInfoRef.current.identityPubKey, details: JSON.stringify({ amount: tx.amount }), }); } if (!unclaimedUtxos.didWork || !unclaimedUtxos.utxos.length) continue; for (const utxo of unclaimedUtxos.utxos) { const { txid, vout } = utxo; const exploraTx = exploraData?.find(t => t.txid === txid); let savedTx = await getSavedTxByTxid(txid); const hasAlreadySaved = !!savedTx; // Get quote for this specific UTXO const { didWork: quoteDidWorkResponse, quote, error, } = await getSparkStaticBitcoinL1AddressQuote( txid, currentMnemonicRef.current, ); if (!quoteDidWorkResponse || !quote) { console.log(error, 'Error getting deposit address quote'); continue; } // Attempt to claim the UTXO const { didWork, error: claimError, response: claimTx, } = await claimnSparkStaticDepositAddress({ transactionId: quote.transactionId, creditAmountSats: quote.creditAmountSats, sspSignature: quote.signature, outputIndex: vout, // Use the vout from the UTXO mnemonic: currentMnemonicRef.current, }); // Add pending transaction if not already saved if (!hasAlreadySaved) { 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) { console.log('Claim static deposit address error', claimError); continue; } // Mark the transfer as handled so transferHandler skips it. // The SDK fires a transfer event for this claim, and without this guard, // debouncedHandleIncomingPayment would write a placeholder record that // races with our own bulkUpdateSparkTransactions call below. handledTransfers.current.add(claimTx.transferId); console.log('Claimed deposit address transaction:', claimTx); // Wait for the transfer to settle await new Promise(res => setTimeout(res, 2000)); const bitcoinTransfer = await getSingleTxDetails( currentMnemonicRef.current, claimTx.transferId, ); let updatedTx = {}; if (!bitcoinTransfer) { // Claim succeeded but the transfer has not settled yet (or the // SDK lookup failed). Keep it pending keyed by the new // transferId; a later run finalizes it. bitcoinTransfer is // undefined here, so it must NOT be dereferenced below. updatedTx = { useTempId: true, id: claimTx.transferId, tempId: quote.transactionId, paymentStatus: 'pending', paymentType: 'bitcoin', accountId: sparkInfoRef.current.identityPubKey, }; } else { let fee = 0; if (exploraTx) { fee = Math.abs(exploraTx?.amount - bitcoinTransfer.totalValue); } else { const savedTxDetails = (() => { try { return JSON.parse(savedTx?.details ?? 'null'); } catch { return null; } })(); fee = Math.abs( savedTxDetails?.amount - bitcoinTransfer.totalValue, ); } updatedTx = { useTempId: true, tempId: quote.transactionId, id: bitcoinTransfer.id, paymentStatus: 'completed', paymentType: 'bitcoin', accountId: sparkInfoRef.current.identityPubKey, details: { amount: bitcoinTransfer.totalValue, fee: fee, totalFee: fee, supportFee: 0, dateAddedToDb: Date.now(), }, }; } console.log('Updated bitcoin transaction:', updatedTx); await bulkUpdateSparkTransactions( [updatedTx], 'fullUpdate-waitBalance', ); // Navigate to confirm screen if we have details if (updatedTx.details) { if (handledNavigatedTxs.current.has(updatedTx.id)) continue; handledNavigatedTxs.current.add(updatedTx.id); if (!isOnSendScreen()) { showToast({ amount: updatedTx.details.amount, duration: 7000, type: 'confirmTx', }); } } } } } catch (err) { console.log('Handle deposit address check error', err); } }; const addPendingTransaction = async (quote, address, sparkState) => { const pendingTx = { id: quote.transactionId, paymentStatus: 'pending', paymentType: 'bitcoin', accountId: sparkState.identityPubKey, details: { fee: 0, amount: quote.creditAmountSats, address: address, time: new Date().getTime(), direction: 'INCOMING', description: i18n.t('contexts.spark.depositLabel'), onChainTxid: quote.transactionId, isRestore: true, // This is a restore payment }, }; await bulkUpdateSparkTransactions([pendingTx], 'transactions'); return pendingTx; }; clearAllDepositIntervals(); if (depositAddressIntervalRef.current) { clearInterval(depositAddressIntervalRef.current); depositAddressIntervalRef.current = null; } if (!initialBitcoinIntervalRun.current) { setTimeout(handleDepositAddressCheck, 1_000 * 5); initialBitcoinIntervalRun.current = true; } const depositIntervalId = setInterval( handleDepositAddressCheck, 1_000 * 60, ); depositAddressIntervalRef.current = depositIntervalId; depositIntervalIds.add(depositIntervalId); return () => { console.log('Cleaning up deposit interval on unmount/dependency change'); if (depositIntervalId) { clearInterval(depositIntervalId); depositIntervalIds.delete(depositIntervalId); } if (depositAddressIntervalRef.current) { clearInterval(depositAddressIntervalRef.current); depositAddressIntervalRef.current = null; } }; }, [ sparkInformation.didConnect, didGetToHomepage, sparkInformation.identityPubKey, showToast, ]); // Balance reconcile lifecycle: // • On background: a balance read can't settle (the WebView request timeout // is neutered), so a read issued before backgrounding would park. Bump the // run id and release the single-flight lock so the parked read can't apply // a stale value or hold the lock, and the foreground branch can issue a // fresh read. balanceVersionRef is bumped so the parked read loses the // ordering guard too. // • On background→active: fire one authoritative reconcile. This lands // balance received while backgrounded (whose event was missed) and recovers // the lane. useEffect(() => { const prev = foregroundReconcileAppStateRef.current; foregroundReconcileAppStateRef.current = appState; if (appState === 'background') { reconcileRunIdRef.current += 1; isReconcilingBalanceRef.current = false; reconcileBalanceAgainRef.current = false; balanceVersionRef.current += 1; return; } if (appState !== 'active' || prev === 'active') return; if (!sparkInformation.didConnect) return; if (!sparkInformation.identityPubKey) return; // Skip the balance reconcile while a send is in flight — the leaves are // locked so this read would return a transient 0/partial. The send's own // paymentWrapperTx → reconcileBalance lands the settled balance instead. if (!isSendingPaymentRef.current) { reconcileBalance(); } // Refresh the local leaves store on foreground (throttled). reconcileLeaves(); // Recover token txs whose token-balance:update fired while backgrounded. reconcileTokenTransactions(false); }, [ appState, sparkInformation.didConnect, sparkInformation.identityPubKey, reconcileBalance, reconcileLeaves, reconcileTokenTransactions, ]); // On the first successful connect for an account: hydrate the leaves summary // from the local SQLite store (so the Wallet Leaves page has cached totals // immediately, even offline), then kick off one forced sync to freshen it. useEffect(() => { if (!sparkInformation.didConnect) return; if (!sparkInformation.identityPubKey) return; if (hydratedLeavesForRef.current === sparkInformation.identityPubKey) return; hydratedLeavesForRef.current = sparkInformation.identityPubKey; const identityPubKey = sparkInformation.identityPubKey; (async () => { try { const stats = await getGlobalLeafStats(identityPubKey); setSparkInformation(prev => ({ ...prev, leaves: { ...(prev.leaves || {}), count: stats.totalLeaves, totalValue: stats.totalValue, lastSyncedAt: stats.lastSyncedAt, }, })); } catch (err) { console.log('hydrate leaves summary error', err); } reconcileLeaves(true); })(); }, [ sparkInformation.didConnect, sparkInformation.identityPubKey, reconcileLeaves, ]); // This function connects to the spark node and sets the session up const connectToSparkWallet = useCallback( async identityPubKey => { const { didWork, error, balanceTimedOut } = await initWallet({ setSparkInformation, filterAndSetTransactions, // toggleGlobalContactsInformation, // globalContactsInformation, mnemonic: accountMnemoinc, sendWebViewRequest, // Restore now runs solely via createRestorePoller in addListeners, so // always load cached txs on connect (the poller's SPARK_TX events layer // in any newly restored txs afterward). hasRestoreCompleted: false, identityPubKey, }); setDidRunNormalConnection(true); // lastConnectedTimeRef.current = Date.now(); if (!didWork) { setSparkInformation(prev => ({ ...prev, didConnect: false })); setSparkConnectionError(error); console.log('Error connecting to spark wallet:', error); return; } // The init balance read timed out and painted the stale snapshot — recover // the real balance out-of-band so it can't stay stale until a foreground // cycle or manual refresh. retryBalanceAfterTimeout(); }, [accountMnemoinc, sendWebViewRequest, retryBalanceAfterTimeout], ); // Function to update db when all reqiured information is loaded useEffect(() => { if (!sparkInformation.didConnect) return; if (!globalContactsInformation?.myProfile) return; if (!sparkInformation.identityPubKey) return; if (!sparkInformation.sparkAddress) return; if (sparkDBaddress.current) return; sparkDBaddress.current = true; if ( !globalContactsInformation.myProfile.sparkAddress || !globalContactsInformation.myProfile.sparkIdentityPubKey ) { toggleGlobalContactsInformation( { myProfile: { ...globalContactsInformation.myProfile, sparkAddress: sparkInformation.sparkAddress, sparkIdentityPubKey: sparkInformation.identityPubKey, }, }, true, ); } }, [ globalContactsInformation.myProfile, sparkInformation.didConnect, sparkInformation.identityPubKey, sparkInformation.sparkAddress, ]); // Cleanup on unmount useEffect(() => { return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } if (balanceDebounceTimeoutRef.current) { clearTimeout(balanceDebounceTimeoutRef.current); } if (balanceDebounceMaxWaitRef.current) { clearTimeout(balanceDebounceMaxWaitRef.current); } if (tokenDebounceTimeoutRef.current) { clearTimeout(tokenDebounceTimeoutRef.current); } if (tokenDebounceMaxWaitRef.current) { clearTimeout(tokenDebounceMaxWaitRef.current); } pendingTransferIds.current.clear(); }; }, []); const txsHashKey = useMemo( () => sparkInformation.transactions .filter(tx => tx.paymentStatus === 'completed') .map(tx => tx.sparkID) .join(','), [sparkInformation.transactions], ); const contextValue = useMemo( () => ({ sparkInformation, txsHashKey, setSparkInformation, // numberOfCachedTxs, // setNumberOfCachedTxs, connectToSparkWallet, sparkConnectionError, setSparkConnectionError, tokensImageCache, showTokensInformation, isSendingPaymentRef, sparkInfoRef, updateHomepageScrollPosition, filterAndSetTransactions, updateHomepageTxPreferance, reconcileLeaves, reconcileExitNodes, }), [ sparkInformation, txsHashKey, setSparkInformation, // numberOfCachedTxs, // setNumberOfCachedTxs, connectToSparkWallet, sparkConnectionError, setSparkConnectionError, tokensImageCache, showTokensInformation, isSendingPaymentRef, sparkInfoRef, updateHomepageScrollPosition, filterAndSetTransactions, updateHomepageTxPreferance, reconcileLeaves, reconcileExitNodes, ], ); return ( {children} ); }; function useSparkWallet() { const context = useContext(SparkWalletManager); if (!context) { throw new Error('useSparkWallet must be used within a SparkWalletProvider'); } return context; } export { SparkWalletManager, SparkWalletProvider, useSparkWallet };