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,
},
});