import React, { useState, useEffect, useRef, useMemo, useCallback, } from 'react'; import { View, StyleSheet, TouchableOpacity, ScrollView, FlatList, } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, } from 'react-native-reanimated'; import { APPROXIMATE_SYMBOL, CENTER, COLORS, CONTENT_KEYBOARD_OFFSET, ICONS, SIZES, USDB_TOKEN_ID, } from '../../../../constants'; import { HIDDEN_OPACITY, INSET_WINDOW_WIDTH, WINDOWWIDTH, } from '../../../../constants/theme'; import { ThemeText } from '../../../../functions/CustomElements'; import displayCorrectDenomination from '../../../../functions/displayCorrectDenomination'; import { useSparkWallet } from '../../../../../context-store/sparkContext'; import { useGlobalContextProvider } from '../../../../../context-store/context'; import { useNodeContext } from '../../../../../context-store/nodeContext'; import { useGlobalThemeContext } from '../../../../../context-store/theme'; import GetThemeColors from '../../../../hooks/themeColors'; import CustomButton from '../../../../functions/CustomElements/button'; import { findBestPool, simulateSwap, swapBitcoinToToken, swapTokenToBitcoin, handleFlashnetError, BTC_ASSET_ADDRESS, USD_ASSET_ADDRESS, getUserSwapHistory, satsToDollars, dollarsToSats, INTEGRATOR_FEE, currentPriceAinBToPriceDollars, } from '../../../../functions/spark/flashnet'; import { useActiveCustodyAccount } from '../../../../../context-store/activeAccount'; import useDebounce from '../../../../hooks/useDebounce'; import { useNavigation } from '@react-navigation/native'; import { useTranslation } from 'react-i18next'; import { updateConfirmAnimation } from '../../../../functions/lottieViewColorTransformer'; import LottieView from 'lottie-react-native'; import { useAppStatus } from '../../../../../context-store/appStatus'; import { bulkUpdateSparkTransactions } from '../../../../functions/spark/transactions'; import ThemeImage from '../../../../functions/CustomElements/themeImage'; import FullLoadingScreen from '../../../../functions/CustomElements/loadingScreen'; import CustomNumberKeyboard from '../../../../functions/CustomElements/customNumberKeyboard'; import { formatBalanceAmount } from '../../../../functions'; import customUUID from '../../../../functions/customUUID'; import { useFlashnet } from '../../../../../context-store/flashnetContext'; import { useUserBalanceContext } from '../../../../../context-store/userBalanceContext'; import useHandleBackPressNew from '../../../../hooks/useHandleBackPressNew'; import FormattedSatText from '../../../../functions/CustomElements/satTextDisplay'; import DropdownMenu from '../../../../functions/CustomElements/dropdownMenu'; import ThemeIcon from '../../../../functions/CustomElements/themeIcon'; import CheckMarkCircle from '../../../../functions/CustomElements/checkMarkCircle'; import { getTimeDisplay } from '../../../../functions/contacts'; import useAdaptiveButtonLayout from '../../../../hooks/useAdaptiveButtonLayout'; import NoContentSceen from '../../../../functions/CustomElements/noContentScreen'; const confirmTxAnimation = require('../../../../assets/confirmTxAnimation.json'); const SLIPPAGE_DROPDOWN_OPTIONS = [ { label: '0.1%', value: '0.1' }, { label: '0.5%', value: '0.5' }, { label: '1%', value: '1' }, { label: '3%', value: '3' }, { label: '5%', value: '5' }, ]; export default function SwapFlowHalfModal({ setContentHeight, handleBackPressFunction, }) { const navigate = useNavigation(); const { poolInfo: globalPoolInfo, togglePoolInfo, swapUSDPriceDollars, swapLimits, } = useFlashnet(); const { dollarBalanceToken, bitcoinBalance } = useUserBalanceContext(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); const { sparkInformation } = useSparkWallet(); const { screenDimensions } = useAppStatus(); const { masterInfoObject } = useGlobalContextProvider(); const { fiatStats } = useNodeContext(); const { theme, darkModeType } = useGlobalThemeContext(); const { backgroundOffset, backgroundColor, textColor } = GetThemeColors(); const { t } = useTranslation(); // Step state const [currentStep, setCurrentStep] = useState('routeSelection'); const dirRef = useRef('forward'); const stepOpacity = useSharedValue(1); const stepTranslateX = useSharedValue(0); // Swap state (migrated from SwapsPage verbatim) const [fromAmount, setFromAmount] = useState(''); const [toAmount, setToAmount] = useState(''); const [fromAsset, setFromAsset] = useState('BTC'); const [toAsset, setToAsset] = useState('USD'); const [isSimulating, setIsSimulating] = useState(false); const [isSwapping, setIsSwapping] = useState(false); const [isLoadingPool, setIsLoadingPool] = useState(true); const [poolInfo, setPoolInfo] = useState(null); const [simulationResult, setSimulationResult] = useState(null); const [error, setError] = useState(null); const [priceImpact, setPriceImpact] = useState(null); const [confirmedSwap, setConfirmedSwap] = useState(null); const [lastEditedField, setLastEditedField] = useState('from'); const [slippagePercent, setSlippagePercent] = useState(''); const [activePercentage, setActivePercentage] = useState(null); // History state (new — replaces ConversionHistory navigation) const [swapHistory, setSwapHistory] = useState({ swaps: [], totalCount: 0 }); const [isLoadingHistory, setIsLoadingHistory] = useState(false); const [finishedInitialHistoryLoad, setFinishedInitialHistoryLoad] = useState(false); // Animated values for amount font sizes (migrated from SwapsPage verbatim) const fromAmountFontSize = useSharedValue(SIZES.large); const toAmountFontSize = useSharedValue(SIZES.large); const fromAmountScale = useSharedValue(1); const toAmountScale = useSharedValue(1); const fromAmountOpacity = useSharedValue(1); const toAmountOpacity = useSharedValue(1); const isInitialMount = useRef(true); const currentRequetId = useRef(null); const lastSimulatedAmount = useRef(null); const isPillPressRef = useRef(false); // Step transition animated style — matches PoolCreationOverlay pattern const stepAnimatedStyle = useAnimatedStyle(() => ({ opacity: stepOpacity.value, transform: [{ translateX: stepTranslateX.value }], pointerEvents: stepOpacity.value > 0.5 ? 'auto' : 'none', })); const HEIGHT_FOR_STEP = useMemo( () => ({ routeSelection: 500, historyExpanded: screenDimensions.height, amountInput: 700, review: screenDimensions.height, confirmation: 500, }), [screenDimensions.height], ); // Resize modal and animate entrance on every step change useEffect(() => { if (setContentHeight) { setContentHeight(HEIGHT_FOR_STEP[currentStep]); } stepOpacity.value = withTiming(1, { duration: 250 }); stepTranslateX.value = withTiming(0, { duration: 250 }); }, [currentStep]); // Navigate between steps with slide direction const navigateToStep = useCallback((newStep, dir = 'forward') => { dirRef.current = dir; stepOpacity.value = 0; stepTranslateX.value = dir === 'forward' ? 30 : -30; setCurrentStep(newStep); }, []); // Android hardware back press — intercepts step-level navigation const handleBackPressAndroid = useCallback(() => { if (isSwapping) return true; if (currentStep === 'historyExpanded') { navigateToStep('routeSelection', 'backward'); return true; } if (currentStep === 'amountInput') { navigateToStep('routeSelection', 'backward'); return true; } if (currentStep === 'review') { navigateToStep('amountInput', 'backward'); return true; } return false; }, [currentStep, navigateToStep, isSwapping]); useHandleBackPressNew(handleBackPressAndroid); // Derived state const tokenInformation = sparkInformation?.tokens?.[USDB_TOKEN_ID]; const btcBalance = sparkInformation?.balance || 0; const displayBalance = fromAsset === 'BTC' ? btcBalance : formatBalanceAmount(dollarBalanceToken, false, masterInfoObject); const fromAssetLabel = fromAsset === 'USD' ? t('constants.dollars_upper') : t('constants.bitcoin_upper'); const toAssetLabel = toAsset === 'USD' ? t('constants.dollars_upper') : t('constants.bitcoin_upper'); const hasEnoughBalance = (fromAsset === 'BTC' && Number(fromAmount) <= Number(bitcoinBalance)) || (fromAsset === 'USD' && Number(fromAmount) <= Number(dollarBalanceToken)); const bitcoinBalanceIsAboveSwapLimit = bitcoinBalance >= swapLimits.bitcoin; const dollarBalanceIsAboveSwapLimit = dollarBalanceToken >= swapLimits.usd; const canSwap = fromAmount && parseFloat(fromAmount) > 0 && toAmount && parseFloat(toAmount) > 0 && !isSimulating && !isSwapping && !isLoadingPool && poolInfo && (!error || error.includes(t('screens.inAccount.swapsPage.checkSwapMessage'))) && hasEnoughBalance; const canProceed = !fromAmount || isSimulating || (!!poolInfo && parseFloat(fromAmount) > 0 && !(fromAsset === 'BTC' && Number(fromAmount) < swapLimits.bitcoin) && !(fromAsset === 'USD' && Number(fromAmount) < swapLimits.usd) && hasEnoughBalance && !!simulationResult && Object.keys(simulationResult).length > 0 && !isSwapping); // ── Pool loading ──────────────────────────────────────────────────────────── useEffect(() => { if (!sparkInformation.didConnectToFlashnet) return; if (Object.keys(globalPoolInfo).length) { setPoolInfo(globalPoolInfo); setIsLoadingPool(false); } else { loadPoolInfo(); } }, [sparkInformation.didConnectToFlashnet]); const clearPageStates = () => { setFromAmount(''); setToAmount(''); setSimulationResult(null); setPriceImpact(null); setError(null); setLastEditedField('from'); isInitialMount.current = true; setConfirmedSwap(null); }; const loadPoolInfo = async () => { setIsLoadingPool(true); setError(null); try { const result = await findBestPool( currentWalletMnemoinc, BTC_ASSET_ADDRESS, USD_ASSET_ADDRESS, ); if (result.didWork && result.pool) { setPoolInfo(result.pool); togglePoolInfo(result.pool); console.log('✓ Found BTC/USDB pool:', { poolId: result.pool.lpPublicKey, tvl: result.pool.tvlAssetB, volume24h: result.pool.volume24hAssetB, }); } else { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.noPoolFoundBackup'), }); } } catch (err) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.loadPoolError'), }); console.warn('Load pool info error:', err); } finally { setIsLoadingPool(false); } }; // ── History (inline, replaces ConversionHistory navigation) ───────────────── useEffect(() => { if (currentStep !== 'historyExpanded') return; if (finishedInitialHistoryLoad) return; loadSwapHistory(); }, [currentStep]); const loadSwapHistory = async () => { setIsLoadingHistory(true); try { const result = await getUserSwapHistory(currentWalletMnemoinc, 20); if (result.didWork) { setSwapHistory({ swaps: result.swaps, totalCount: result.totalCount }); } } catch (err) { navigate.navigate('ErrorScreen', { errorMessage: t( 'screens.inAccount.swapHistory.loadingSwapHistoryError', ), }); } finally { setIsLoadingHistory(false); setFinishedInitialHistoryLoad(true); } }; const loadMoreHistory = async () => { setIsLoadingHistory(true); try { const result = await getUserSwapHistory( currentWalletMnemoinc, 20, swapHistory.swaps.length, ); if (result.didWork) { setSwapHistory(prev => ({ swaps: [...prev.swaps, ...result.swaps], totalCount: result.totalCount, })); } } catch (err) { navigate.navigate('ErrorScreen', { errorMessage: t( 'screens.inAccount.swapHistory.loadingSwapHistoryError', ), }); } finally { setIsLoadingHistory(false); } }; const formatDate = useCallback(timestamp => { const date = new Date(timestamp); const now = new Date(); const diffTime = Math.abs(now.getTime() - date.getTime()); const timeDifferenceMinutes = diffTime / (1000 * 60); const timeDifferenceHours = diffTime / (1000 * 60 * 60); const timeDifferenceDays = diffTime / (1000 * 60 * 60 * 24); const timeDifferenceYears = diffTime / (1000 * 60 * 60 * 24 * 365); return getTimeDisplay( timeDifferenceMinutes, timeDifferenceHours, timeDifferenceDays, timeDifferenceYears, ); }, []); // ── Simulation (migrated verbatim from SwapsPage) ──────────────────────────── useEffect(() => { const uuid = customUUID(); currentRequetId.current = uuid; if (lastEditedField === 'from') { if ( fromAmount.length && !isNaN(fromAmount) && parseFloat(fromAmount) > 0 ) { if (lastSimulatedAmount.current === fromAmount && !!toAmount.length) return; lastSimulatedAmount.current = fromAmount; setIsSimulating(true); setError(null); simulateSwapAmount(fromAmount, 'from', uuid); } else { setToAmount(''); setSimulationResult(null); setPriceImpact(null); setError(''); setIsSimulating(false); } } else if (lastEditedField === 'to') { if (toAmount.length && !isNaN(toAmount) && parseFloat(toAmount) > 0) { if (lastSimulatedAmount.current === toAmount && fromAmount) return; lastSimulatedAmount.current = toAmount; setIsSimulating(true); setError(null); simulateSwapAmount(toAmount, 'to', uuid); } else { setFromAmount(''); setSimulationResult(null); setPriceImpact(null); setError(''); setIsSimulating(false); } } }, [fromAmount, toAmount, fromAsset, toAsset, poolInfo, lastEditedField]); const simulateSwapAmount = useDebounce(async (amount, direction, uuid) => { if (!poolInfo) return; if (uuid !== currentRequetId.current) { console.log('New request created, blocking...'); return; } if (!amount) { setError(''); return; } try { const isBtcToUsdb = fromAsset === 'BTC'; const decimals = tokenInformation?.tokenMetadata?.decimals || 6; const isForwardDirection = direction === 'from'; let amountInSmallestUnits; let assetInAddress; let assetOutAddress; if (isForwardDirection) { amountInSmallestUnits = isBtcToUsdb ? Math.floor(parseFloat(amount)) : Math.floor(parseFloat(amount) * Math.pow(10, decimals)); assetInAddress = isBtcToUsdb ? BTC_ASSET_ADDRESS : USD_ASSET_ADDRESS; assetOutAddress = isBtcToUsdb ? USD_ASSET_ADDRESS : BTC_ASSET_ADDRESS; } else { amountInSmallestUnits = isBtcToUsdb ? Math.floor(parseFloat(amount) * Math.pow(10, decimals)) : Math.floor(parseFloat(amount)); assetInAddress = isBtcToUsdb ? USD_ASSET_ADDRESS : BTC_ASSET_ADDRESS; assetOutAddress = isBtcToUsdb ? BTC_ASSET_ADDRESS : USD_ASSET_ADDRESS; } const result = await simulateSwap(currentWalletMnemoinc, { poolId: poolInfo.lpPublicKey, assetInAddress, assetOutAddress, amountIn: amountInSmallestUnits, }); if (uuid !== currentRequetId.current) { console.log('New request created, blocking...'); return; } if (result.didWork && result.simulation) { setSimulationResult(result.simulation); setPriceImpact(parseFloat(result.simulation.priceImpact)); let outputAmount; if (isForwardDirection) { outputAmount = isBtcToUsdb ? ( parseFloat(result.simulation.expectedOutput) / Math.pow(10, decimals) ).toFixed(2) : parseFloat(result.simulation.expectedOutput).toFixed(0); setToAmount(outputAmount); } else { outputAmount = isBtcToUsdb ? parseFloat(result.simulation.expectedOutput).toFixed(0) : ( parseFloat(result.simulation.expectedOutput) / Math.pow(10, decimals) ).toFixed(2); setFromAmount(outputAmount); } if (parseFloat(result.simulation.priceImpact) > 3) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.highPriceImpact', { priceImpact: result.simulation.priceImpact, }), }); } } else { const errorInfo = handleFlashnetError({ ...result.details, error: result.error, }); setError(true); if (isForwardDirection) { setToAmount('0'); } else { setFromAmount('0'); } } } catch (err) { console.warn('Simulate swap error:', err); setError(true); if (direction === 'from') { setToAmount('0'); } else { setFromAmount('0'); } } finally { setIsSimulating(false); } }, 500); // ── Handlers (migrated verbatim from SwapsPage) ────────────────────────────── const handleSwapAssets = () => { setFromAsset(toAsset); setToAsset(fromAsset); setFromAmount(''); setToAmount(''); setLastEditedField('from'); currentRequetId.current = null; setSimulationResult(null); setPriceImpact(null); setError(null); }; // Step 1 currency selection — sets fromAsset, auto-sets toAsset, clears amounts const handleSelectFromAsset = newFrom => { if (fromAsset === newFrom) return; setFromAsset(newFrom); setToAsset(newFrom === 'BTC' ? 'USD' : 'BTC'); setFromAmount(''); setToAmount(''); setSimulationResult(null); setActivePercentage(null); }; const handleFromAmountChange = (value, direction) => { if (value === '' || /^\d*\.?\d*$/.test(value)) { if (!isPillPressRef.current) { setActivePercentage(null); } isPillPressRef.current = false; if (direction === 'from') { setFromAmount(value); setLastEditedField('from'); } else { setToAmount(value); setLastEditedField('to'); } } }; const setPercentage = percent => { const balance = fromAsset === 'BTC' ? btcBalance : dollarBalanceToken; const decimals = fromAsset === 'BTC' ? 0 : 2; const multiplier = Math.pow(10, decimals); const amount = (Math.floor(balance * percent * multiplier) / multiplier) .toFixed(decimals) .toString(); setFromAmount(amount); setLastEditedField('from'); }; const handleInputPress = direction => { setLastEditedField(direction); }; const handleKeyboardInput = value => { if (lastEditedField === 'from') { handleFromAmountChange(value, 'from'); } else { handleFromAmountChange(value, 'to'); } }; const backLabel = t('constants.back'); const confirmLabel = t('constants.confirm'); const { shouldStack, containerProps, getLabelProps } = useAdaptiveButtonLayout([backLabel, confirmLabel]); const getAmountFontSize = amount => { if (!amount) return SIZES.large; const length = amount.toString().length; if (length <= 6) return SIZES.large; if (length <= 9) return SIZES.medium; if (length <= 12) return SIZES.smedium; if (length <= 15) return SIZES.small; return SIZES.xSmall; }; useEffect(() => { const newSize = getAmountFontSize(fromAmount); if (isInitialMount.current) { fromAmountFontSize.value = newSize; } else { fromAmountFontSize.value = withSpring(newSize, { damping: 500, stiffness: 400, }); } }, [fromAmount]); useEffect(() => { const newSize = getAmountFontSize(toAmount); if (isInitialMount.current) { toAmountFontSize.value = newSize; isInitialMount.current = false; } else { toAmountFontSize.value = withSpring(newSize, { damping: 500, stiffness: 400, }); } }, [toAmount]); const fromAmountAnimatedStyle = useAnimatedStyle(() => ({ fontSize: fromAmountFontSize.value, opacity: fromAmountOpacity.value, transform: [{ scale: fromAmountScale.value }], })); const toAmountAnimatedStyle = useAnimatedStyle(() => ({ fontSize: toAmountFontSize.value, opacity: toAmountOpacity.value, transform: [{ scale: toAmountScale.value }], })); const executeSwapAction = async () => { if (!fromAmount) { navigateToStep('routeSelection', 'backwards'); return; } if (!poolInfo || !fromAmount || parseFloat(fromAmount) <= 0) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.invalidAmount'), }); return; } if (fromAsset === 'BTC' && fromAmount < swapLimits.bitcoin) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.minBTCError', { min: displayCorrectDenomination({ amount: swapLimits.bitcoin, masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'sats', }, fiatStats, }), }), }); return; } if (fromAsset === 'USD' && fromAmount < swapLimits.usd) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.minUSDError', { min: displayCorrectDenomination({ amount: swapLimits.usd, masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'fiat', }, fiatStats, forceCurrency: 'USD', convertAmount: false, }), }), }); return; } if (isSimulating) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.simulationInProgress'), }); return; } if (!hasEnoughBalance) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.insufficientBalance'), }); return; } if (!simulationResult || !Object.keys(simulationResult).length) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.simulationError'), }); return; } if (isSwapping) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.swapInProgressError'), }); return; } if (lastEditedField === 'to') { setIsSimulating(true); setError(null); try { const isBtcToUsdb = fromAsset === 'BTC'; const decimals = tokenInformation?.tokenMetadata?.decimals || 6; const amountInSmallestUnits = isBtcToUsdb ? Math.floor(parseFloat(fromAmount)) : Math.floor(parseFloat(fromAmount) * Math.pow(10, decimals)); const assetInAddress = isBtcToUsdb ? BTC_ASSET_ADDRESS : USD_ASSET_ADDRESS; const assetOutAddress = isBtcToUsdb ? USD_ASSET_ADDRESS : BTC_ASSET_ADDRESS; const uuid = customUUID(); currentRequetId.current = uuid; const result = await simulateSwap(currentWalletMnemoinc, { poolId: poolInfo.lpPublicKey, assetInAddress, assetOutAddress, amountIn: amountInSmallestUnits, }); if (uuid !== currentRequetId.current) { console.log('New request created during re-simulation, blocking...'); setIsSimulating(false); return; } if (result.didWork && result.simulation) { setSimulationResult(result.simulation); setPriceImpact(parseFloat(result.simulation.priceImpact)); const outputAmount = isBtcToUsdb ? ( parseFloat(result.simulation.expectedOutput) / Math.pow(10, decimals) ).toFixed(2) : parseFloat(result.simulation.expectedOutput).toFixed(0); lastSimulatedAmount.current = fromAmount; setLastEditedField('from'); setToAmount(outputAmount); if (parseFloat(result.simulation.priceImpact) > 3) { navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.highPriceImpact', { priceImpact: result.simulation.priceImpact, }), }); setIsSimulating(false); return; } setIsSimulating(false); navigateToStep('review', 'forward'); } else { const errorInfo = handleFlashnetError({ ...result.details, error: result.error, }); setError(true); setIsSimulating(false); navigate.navigate('ErrorScreen', { errorMessage: errorInfo.userMessage || result.error, }); } } catch (err) { console.warn('Re-simulate swap error:', err); setError(true); setIsSimulating(false); navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.simulationError'), }); } } else { navigateToStep('review', 'forward'); } }; const handleAcceptReview = () => { const showConfirmScreen = priceImpact > 5; if (!showConfirmScreen) { performSwap(); return; } navigate.navigate('ConfirmActionPage', { confirmMessage: t('screens.inAccount.swapsPage.priceImpact', { impact: priceImpact?.toFixed(2) || 'N/A', }), confirmFunction: () => performSwap(), }); }; const handleDropdownToggle = value => { setSlippagePercent(value.value); }; const performSwap = async () => { setIsSwapping(true); setError(null); try { const isBtcToUsdb = fromAsset === 'BTC'; const decimals = tokenInformation?.tokenMetadata?.decimals || 6; const amountInSmallestUnits = isBtcToUsdb ? Math.floor(parseFloat(fromAmount)) : Math.floor(parseFloat(fromAmount) * Math.pow(10, decimals)); const result = isBtcToUsdb ? await swapBitcoinToToken(currentWalletMnemoinc, { tokenAddress: USD_ASSET_ADDRESS, amountSats: amountInSmallestUnits, poolId: poolInfo.lpPublicKey, maxSlippageBps: (slippagePercent || 5) * 100, }) : await swapTokenToBitcoin(currentWalletMnemoinc, { tokenAddress: USD_ASSET_ADDRESS, tokenAmount: amountInSmallestUnits, poolId: poolInfo.lpPublicKey, maxSlippageBps: (slippagePercent || 5) * 100, }); console.log('Execute swap response', result); if (result.didWork && result.swap) { const realReceivedAmount = isBtcToUsdb ? dollarsToSats( result.swap.amountOut / Math.pow(10, decimals), result.swap.executionPrice, ) : parseFloat(result.swap.amountOut).toFixed(0); const realFeeAmount = Math.round( dollarsToSats( parseFloat(result.swap.feeAmount) / Math.pow(10, decimals), result.swap.executionPrice, ), ); setConfirmedSwap({ ...result.swap, realReceivedAmount, realFeeAmount }); navigateToStep('confirmation', 'forward'); const userSwaps = await getUserSwapHistory(currentWalletMnemoinc, 5); const swap = userSwaps.swaps.find( savedSwap => savedSwap.outboundTransferId === result.swap.outboundTransferId, ); let incomingTransfer, outgoingTransfer; if (swap) { if (isBtcToUsdb) { incomingTransfer = { id: swap.outboundTransferId, paymentStatus: 'completed', paymentType: 'spark', accountId: sparkInformation.identityPubKey, details: { fee: realFeeAmount, totalFee: realFeeAmount, supportFee: 0, amount: parseFloat(result.swap.amountOut), description: t( 'screens.inAccount.swapsPage.paymentDescription_incoming', { swapDirection: t( 'screens.inAccount.swapsPage.swapDirection_btcusd', ), }, ), address: sparkInformation.sparkAddress, time: Date.now(), createdAt: Date.now(), direction: 'INCOMING', isLRC20Payment: true, LRC20Token: USDB_TOKEN_ID, showSwapLabel: true, currentPriceAInB: result.swap.executionPrice, }, }; outgoingTransfer = { id: swap.inboundTransferId, paymentStatus: 'completed', paymentType: 'spark', accountId: sparkInformation.identityPubKey, details: { fee: 0, totalFee: 0, supportFee: 0, amount: parseFloat(swap.amountIn), description: t( 'screens.inAccount.swapsPage.paymentDescription_outgoing', { swapDirection: t( 'screens.inAccount.swapsPage.swapDirection_btcusd', ), }, ), address: sparkInformation.sparkAddress, time: Date.now(), createdAt: Date.now(), direction: 'OUTGOING', showSwapLabel: true, currentPriceAInB: result.swap.executionPrice, }, }; } else { incomingTransfer = { id: swap.outboundTransferId, paymentStatus: 'pending', paymentType: 'spark', accountId: sparkInformation.identityPubKey, details: { fee: realFeeAmount, totalFee: realFeeAmount, supportFee: 0, amount: parseFloat(swap.amountOut), description: t( 'screens.inAccount.swapsPage.paymentDescription_incoming', { swapDirection: t( 'screens.inAccount.swapsPage.swapDirection_usdbtc', ), }, ), address: sparkInformation.sparkAddress, time: Date.now(), createdAt: Date.now(), direction: 'INCOMING', showSwapLabel: true, currentPriceAInB: result.swap.executionPrice, }, }; outgoingTransfer = { id: swap.inboundTransferId, paymentStatus: 'completed', paymentType: 'spark', accountId: sparkInformation.identityPubKey, details: { fee: 0, totalFee: 0, supportFee: 0, amount: parseFloat(swap.amountIn), description: t( 'screens.inAccount.swapsPage.paymentDescription_outgoing', { swapDirection: t( 'screens.inAccount.swapsPage.swapDirection_usdbtc', ), }, ), address: sparkInformation.sparkAddress, time: Date.now(), createdAt: Date.now(), direction: 'OUTGOING', isLRC20Payment: true, LRC20Token: USDB_TOKEN_ID, showSwapLabel: true, currentPriceAInB: result.swap.executionPrice, }, }; } } if (incomingTransfer) { bulkUpdateSparkTransactions( [incomingTransfer, outgoingTransfer], 'fullUpdate', ); } } else { const errorInfo = handleFlashnetError({ ...result.details, error: result.error, }); console.log(errorInfo, 'error info'); navigate.navigate('ErrorScreen', { errorMessage: errorInfo.userMessage || result.error, }); } } catch (err) { console.warn('Execute swap error:', err); navigate.navigate('ErrorScreen', { errorMessage: t('screens.inAccount.swapsPage.fullSwapError'), }); } finally { setIsSwapping(false); } }; const confirmAnimation = useMemo( () => updateConfirmAnimation( confirmTxAnimation, theme ? (darkModeType ? 'lightsOut' : 'dark') : 'light', ), [theme, darkModeType], ); // ── Review step computed values (safe to call unconditionally) ─────────────── const isBtcToUsdbReview = fromAsset === 'BTC'; const reviewDecimals = tokenInformation?.tokenMetadata?.decimals || 6; const reviewSwapFee = simulationResult ? isBtcToUsdbReview ? Number(simulationResult.feePaidAssetIn / 1000000) : dollarsToSats( simulationResult.feePaidAssetIn / 1000000, poolInfo?.currentPriceAInB, ) : 0; const reviewLpFee = simulationResult && isBtcToUsdbReview ? (simulationResult.expectedOutput * INTEGRATOR_FEE) / 1000000 : 0; const reviewExchangeRate = poolInfo ? isBtcToUsdbReview ? `${displayCorrectDenomination({ amount: 1, masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'sats', }, })} ${APPROXIMATE_SYMBOL} ${displayCorrectDenomination({ amount: Number(satsToDollars(1, poolInfo.currentPriceAInB)).toFixed( 6, ), masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'fiat', }, forceCurrency: 'USD', convertAmount: false, })}` : `${displayCorrectDenomination({ amount: 1, masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'fiat', }, forceCurrency: 'USD', convertAmount: false, })} ${APPROXIMATE_SYMBOL} ${displayCorrectDenomination({ amount: Number(dollarsToSats(1, poolInfo.currentPriceAInB)).toFixed( 0, ), masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'sats', }, })}` : ''; const reviewFormattedBitcoinPrice = displayCorrectDenomination({ amount: Number(swapUSDPriceDollars).toFixed(2), masterInfoObject: { ...masterInfoObject, userBalanceDenomination: 'fiat', }, forceCurrency: 'USD', convertAmount: false, }); const reviewFormattedFee = displayCorrectDenomination({ amount: Number(reviewSwapFee + reviewLpFee).toFixed( isBtcToUsdbReview ? 3 : 0, ), masterInfoObject: { ...masterInfoObject, userBalanceDenomination: isBtcToUsdbReview ? 'fiat' : 'sats', }, forceCurrency: 'USD', convertAmount: false, }); // ── Render ─────────────────────────────────────────────────────────────────── if (!dollarBalanceToken || !bitcoinBalance) { return ( ); } return ( {/* ── Step 1: Route Selection ─────────────────────────────────── */} {currentStep === 'routeSelection' && ( <> {isLoadingPool && ( )} {!isLoadingPool && !poolInfo && ( )} {!isLoadingPool && poolInfo && ( <> {/* BTC selection card */} handleSelectFromAsset('BTC')} activeOpacity={0.7} disabled={!bitcoinBalanceIsAboveSwapLimit} style={[ styles.selectionCard, { backgroundColor: theme && darkModeType ? backgroundColor : backgroundOffset, opacity: bitcoinBalanceIsAboveSwapLimit ? 1 : HIDDEN_OPACITY, }, ]} > {/* USD selection card */} handleSelectFromAsset('USD')} activeOpacity={0.7} disabled={!dollarBalanceIsAboveSwapLimit} style={[ styles.selectionCard, { backgroundColor: theme && darkModeType ? backgroundColor : backgroundOffset, opacity: dollarBalanceIsAboveSwapLimit ? 1 : HIDDEN_OPACITY, }, ]} > navigateToStep('historyExpanded', 'forward')} activeOpacity={0.7} > { if ( !bitcoinBalanceIsAboveSwapLimit && !dollarBalanceIsAboveSwapLimit ) return; navigateToStep('amountInput', 'forward'); }} /> )} )} {/* ── History Expanded ────────────────────────────────────────── */} {currentStep === 'historyExpanded' && ( navigateToStep('routeSelection', 'backward')} activeOpacity={0.7} style={styles.backButton} > {!finishedInitialHistoryLoad ? ( ) : ( item.id} renderItem={({ item }) => { const isBtcToUsdSwap = item.assetInAddress === BTC_ASSET_ADDRESS; const formattedAmountOut = isBtcToUsdSwap ? formatBalanceAmount( item.amountOut / Math.pow(10, 6), false, masterInfoObject, ) : item.amountOut; const price = currentPriceAinBToPriceDollars( item.price, ).toFixed(2); const date = formatDate(item.timestamp); return ( ); }} ListEmptyComponent={ } ListFooterComponent={ swapHistory.swaps.length < swapHistory.totalCount ? ( ) : null } contentContainerStyle={ swapHistory.totalCount === 0 ? styles.emptyListContainer : styles.historyList } showsVerticalScrollIndicator={false} /> )} )} {/* ── Step 2: Amount Input ────────────────────────────────────── */} {currentStep === 'amountInput' && ( <> {/* Amount display card — from row + divider + to row */} {/* From row */} handleInputPress('from')} activeOpacity={0.7} > {fromAsset === 'BTC' ? ( ) : ( )} {displayCorrectDenomination({ amount: fromAmount || 0, masterInfoObject: { ...masterInfoObject, userBalanceDenomination: fromAsset === 'BTC' ? 'sats' : 'fiat', }, fiatStats, convertAmount: fromAsset === 'BTC', forceCurrency: 'USD', })} {/* To row */} handleInputPress('to')} activeOpacity={0.7} > {toAsset === 'BTC' ? ( ) : ( )} {isSimulating ? ( ) : ( {`${APPROXIMATE_SYMBOL}${displayCorrectDenomination({ amount: toAmount || 0, masterInfoObject: { ...masterInfoObject, userBalanceDenomination: toAsset === 'BTC' ? 'sats' : 'fiat', }, fiatStats, convertAmount: toAsset === 'BTC', forceCurrency: 'USD', })}`} )} {/* Quick-select % pills */} {[ { label: '25%', value: '25', pct: 0.25 }, { label: '50%', value: '50', pct: 0.5 }, { label: '75%', value: '75', pct: 0.75 }, { label: '100%', value: '100', pct: 1 }, ].map(btn => ( { isPillPressRef.current = true; setActivePercentage(btn.value); setPercentage(btn.pct); }} activeOpacity={0.7} disabled={isSwapping || isLoadingPool} > ))} )} {/* ── Step 3: Review ──────────────────────────────────────────── */} {currentStep === 'review' && ( <> {/* Rate card */} {/* Fee card */} {/* Slippage card */} { navigate.navigate('InformationPopup', { textContent: t( 'screens.inAccount.swapsPage.slippageDesc', ), buttonText: t('constants.iunderstand'), }); }} style={styles.slippageInfoContainer} > navigateToStep('amountInput', 'backward')} buttonStyles={[ { opacity: isSwapping ? HIDDEN_OPACITY : 1, }, shouldStack ? styles.buttonStacked : styles.buttonColumn, ]} enableElipsis={false} {...getLabelProps(0)} textContent={backLabel} disabled={isSwapping} /> )} {/* ── Step 4: Confirmation ────────────────────────────────────── */} {currentStep === 'confirmation' && ( <> { clearPageStates(); navigateToStep('routeSelection', 'backward'); }} textContent={t('screens.inAccount.swapsPage.newSwap')} /> )} ); } // ── SwapModalNavbar — back chevron + iOS-style breadcrumb label ────────────── function SwapModalNavbar({ onBack, backLabel }) { return ( {/* {backLabel ? ( ) : null} */} ); } // ── HandleKeyboardRender (migrated verbatim from SwapsPage) ────────────────── function HandleKeyboardRender({ lastEditedField, toAsset, fromAsset, handleKeyboardInput, fromAmount, toAmount, }) { const [amount, setAmount] = useState( lastEditedField === 'from' ? fromAmount : toAmount, ); const fromAmountRef = useRef(fromAmount); const toAmountRef = useRef(toAmount); const amountRef = useRef(amount); useEffect(() => { amountRef.current = amount; }, [amount]); useEffect(() => { fromAmountRef.current = fromAmount; }, [fromAmount]); useEffect(() => { toAmountRef.current = toAmount; }, [toAmount]); useEffect(() => { handleKeyboardInput(amount); }, [amount]); useEffect(() => { if (fromAmount !== amountRef.current && lastEditedField === 'from') { setAmount(fromAmount); } else if (toAmount !== amountRef.current && lastEditedField === 'to') { setAmount(toAmount); } }, [fromAmount, toAmount, lastEditedField]); return ( ); } // ── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ container: { flex: 1, }, stepWrapper: { flex: 1, }, stepContent: { flex: 1, paddingHorizontal: 16, // paddingTop: 16, }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingVertical: 60, }, loadingText: { marginTop: 16, fontSize: SIZES.medium, opacity: HIDDEN_OPACITY, }, errorStateContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, errorStateTitle: { fontSize: SIZES.xLarge, fontWeight: '500', marginBottom: 8, }, errorStateMessage: { fontSize: SIZES.medium, textAlign: 'center', opacity: HIDDEN_OPACITY, lineHeight: 22, }, card: { width: '100%', borderRadius: 16, padding: 15, }, cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, }, label: { fontSize: SIZES.smedium, opacity: HIDDEN_OPACITY, includeFontPadding: false, }, assetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, assetInfo: { flexDirection: 'row', alignItems: 'center', gap: 12, }, iconContainer: { width: 40, height: 40, borderRadius: 24, justifyContent: 'center', alignItems: 'center', }, assetText: { fontSize: SIZES.large, fontWeight: '500', includeFontPadding: false, }, amountInput: { fontWeight: '500', textAlign: 'right', minWidth: 100, includeFontPadding: false, }, cardContainer: { gap: 5, alignItems: 'center', justifyContent: 'center', }, swapButton: { width: 40, height: 40, borderRadius: 26, justifyContent: 'center', alignItems: 'center', alignSelf: 'center', position: 'absolute', zIndex: 10, }, historyButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 12, ...CENTER, }, historyButtonText: { fontSize: SIZES.smedium, opacity: HIDDEN_OPACITY, includeFontPadding: false, }, historyContainer: { paddingHorizontal: 0, paddingTop: 0, }, historyHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, // paddingTop: 16, paddingBottom: 16, }, backButton: { marginRight: 8, }, historyTitle: { fontSize: SIZES.large, fontWeight: '500', includeFontPadding: false, }, historyList: { gap: 10, paddingHorizontal: 16, paddingBottom: 16, }, emptyListContainer: { flexGrow: 1, }, transactionRow: { width: WINDOWWIDTH, flexDirection: 'row', alignItems: 'center', paddingVertical: 16, paddingHorizontal: 10, ...CENTER, borderRadius: 8, }, transactionContent: { flex: 1, justifyContent: 'center', marginLeft: 5, }, transactionTitle: { marginBottom: 2, includeFontPadding: false, }, transactionSubtext: { fontSize: SIZES.small, opacity: HIDDEN_OPACITY, includeFontPadding: false, }, amountContainer: { alignItems: 'flex-end', }, amountText: { marginBottom: 2, includeFontPadding: false, }, emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 40, }, emptyTitle: { fontSize: SIZES.large, fontWeight: '500', marginTop: 16, marginBottom: 8, textAlign: 'center', }, emptySubtext: { fontSize: SIZES.smedium, opacity: HIDDEN_OPACITY, textAlign: 'center', }, footerContainer: { marginTop: 20, alignItems: 'center', }, // ── Navbar ─────────────────────────────────────────────────────────────── navbar: { flexDirection: 'row', alignItems: 'center', height: 52, paddingHorizontal: 16, }, navbarBackBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, }, navbarLabel: { fontSize: SIZES.smedium, includeFontPadding: false, }, // ── Step shared ─────────────────────────────────────────────────────────── stepTitle: { fontWeight: '500', fontSize: SIZES.large, marginBottom: 20, includeFontPadding: false, }, // ── Step 1 selection cards ──────────────────────────────────────────────── selectionCard: { width: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 12, // borderWidth: 2, paddingVertical: 14, paddingHorizontal: 12, marginBottom: 12, }, selectionIconContainer: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', }, selectionTextContainer: { flex: 1, marginLeft: 14, }, selectionAssetName: { fontWeight: '500', fontSize: SIZES.large, includeFontPadding: false, }, selectionBalance: { opacity: 0.7, includeFontPadding: false, }, // ── Step 2 amount display ───────────────────────────────────────────────── amountDisplayCard: { width: '100%', borderRadius: 12, overflow: 'hidden', marginBottom: 16, }, amountRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 16, paddingHorizontal: 14, gap: 10, }, amountRowIcon: { width: 32, height: 32, borderRadius: 16, justifyContent: 'center', alignItems: 'center', }, amountRowText: { flex: 1, fontWeight: '500', includeFontPadding: false, }, amountRowCurrency: { fontSize: SIZES.smedium, opacity: HIDDEN_OPACITY, includeFontPadding: false, }, amountRowDivider: { height: 1, marginHorizontal: 14, }, // ── Step 2 percentage pills ─────────────────────────────────────────────── pillRow: { flexDirection: 'row', gap: 8, marginBottom: 16, }, pill: { flex: 1, paddingVertical: 8, borderRadius: 20, alignItems: 'center', }, pillText: { fontSize: SIZES.small, fontWeight: '500', includeFontPadding: false, }, disclaimer: { textAlign: 'center', fontSize: SIZES.xSmall, opacity: HIDDEN_OPACITY, marginTop: 12, maxWidth: 300, ...CENTER, }, reviewScroll: { flexGrow: 1, paddingHorizontal: 16, // paddingTop: 16, }, reviewLabel: { fontSize: SIZES.smedium, includeFontPadding: false, }, reviewAmount: { fontSize: SIZES.smedium, includeFontPadding: false, }, reviewSection: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, slippageInfoContainer: { flexDirection: 'row', alignItems: 'center', marginRight: 5, }, confirmContainer: { alignItems: 'center', justifyContent: 'center', }, confirmButtons: { marginTop: 'auto', width: '100%', }, buttonRow: { flexDirection: 'row', gap: 10, marginTop: CONTENT_KEYBOARD_OFFSET, width: INSET_WINDOW_WIDTH, ...CENTER, }, containerRow: { flexDirection: 'row', justifyContent: 'center', }, containerStacked: { flexDirection: 'column', justifyContent: 'flex-start', }, buttonStacked: { width: '100%', }, buttonColumn: { flex: 1, }, });