Files
com.blitzwallet/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js
T
Blake KaufmanandGitHub c2a0fce581 Simplify payment confirmation screen (#1029)
* update translations

* simplifying payments display

* remove unconsistant icons

* adding translations

* fix usdb value display

* fix tokens bug
2026-07-25 11:34:25 -04:00

1824 lines
62 KiB
JavaScript

import { StyleSheet, View, TouchableOpacity, ScrollView } from 'react-native';
import {
CENTER,
CONTENT_KEYBOARD_OFFSET,
QUICK_PAY_STORAGE_KEY,
SATSPERBITCOIN,
USDB_TOKEN_ID,
} from '../../../../constants';
import displayCorrectDenomination from '../../../../functions/displayCorrectDenomination';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useGlobalContextProvider } from '../../../../../context-store/context';
import {
CustomKeyboardAvoidingView,
GlobalThemeView,
ThemeText,
} from '../../../../functions/CustomElements';
import SendTransactionFeeInfo from './components/feeInfo';
import usePaymentValidation from './functions/paymentValidation';
import decodeSendAddress from './functions/decodeSendAdress';
import { useNavigation } from '@react-navigation/native';
// import {useWebView} from '../../../../../context-store/webViewContext';
import GetThemeColors from '../../../../hooks/themeColors';
import CustomSearchInput from '../../../../functions/CustomElements/searchInput';
import FullLoadingScreen from '../../../../functions/CustomElements/loadingScreen';
import AcceptButtonSendPage from './components/acceptButton';
import NumberInputSendPage from './components/numberInput';
import FormattedBalanceInput from '../../../../functions/CustomElements/formattedBalanceInput';
import { useGlobalThemeContext } from '../../../../../context-store/theme';
import { useNodeContext } from '../../../../../context-store/nodeContext';
import ErrorWithPayment from './components/errorScreen';
import SwipeButtonNew from '../../../../functions/CustomElements/sliderButton';
import { crashlyticsLogReport } from '../../../../functions/crashlyticsLogs';
import {
isSendingPayingEventEmiiter,
SENDING_PAYMENT_EVENT_NAME,
useSparkWallet,
} from '../../../../../context-store/sparkContext';
import { sparkPaymenWrapper } from '../../../../functions/spark/payments';
import InvoiceInfo from './components/invoiceInfo';
import formatSparkPaymentAddress from './functions/formatSparkPaymentAddress';
import ChoosePaymentMethod from './components/choosePaymentMethodContainer';
import ChooseLRC20TokenContainer from './components/chooseLRC20TokenContainer';
import { useActiveCustodyAccount } from '../../../../../context-store/activeAccount';
import { useTranslation } from 'react-i18next';
import {
COLORS,
HIDDEN_OPACITY,
INSET_WINDOW_WIDTH,
SIZES,
WINDOWWIDTH,
} from '../../../../constants/theme';
import { SliderProgressAnimation } from '../../../../functions/CustomElements/sendPaymentAnimation';
import { InputTypes } from 'bitcoin-address-parser';
import CustomSettingsTopBar from '../../../../functions/CustomElements/settingsTopBar';
import { useWebView } from '../../../../../context-store/webViewContext';
import { useGlobalInsets } from '../../../../../context-store/insetsProvider';
import EmojiQuickBar from '../../../../functions/CustomElements/emojiBar';
import { useGlobalContactsInfo } from '../../../../../context-store/globalContacts';
import { bulkUpdateSparkTransactions } from '../../../../functions/spark/transactions';
import { useKeysContext } from '../../../../../context-store/keys';
import { useUserBalanceContext } from '../../../../../context-store/userBalanceContext';
import CustomButton from '../../../../functions/CustomElements/button';
import { useFlashnet } from '../../../../../context-store/flashnetContext';
import SwapRatesChangedState from './components/swapRatesChangedState';
import {
dollarsToSats,
satsToDollars,
getLightningPaymentQuote,
USD_ASSET_ADDRESS,
} from '../../../../functions/spark/flashnet';
import useCurrencyDisplay from '../../../../hooks/useCurrencyDisplay';
import useDisplayCurrencyController from '../../../../hooks/useDisplayCurrencyController';
import normalizeLNURLAddress from '../../../../functions/lnurl/normalizeLNURLAddress';
import { publishMessage } from '../../../../functions/messaging/publishMessage';
import { useToast } from '../../../../../context-store/toastManager';
import useDebounce from '../../../../hooks/useDebounce';
import customUUID from '../../../../functions/customUUID';
import { useBudgetWarning } from '../../../../hooks/useBudgetWarning';
import { getLNAddressForLiquidPayment } from './functions/payments';
import formatTokensNumber from '../../../../functions/lrc20/formatTokensBalance';
import {
getDefaultDisplayCurrency,
resolveUsdFiatStats,
} from '../../../../functions/displayCurrency';
import CurrencySwitchButton from '../../../../functions/CustomElements/currencySwitchButton';
import SecondaryAmountDisplay from './components/secondaryAmountDisplay';
import { lnurlCurrencyToRate } from '../../../../functions/sendBitcoin/lnurlCurrencyRate';
import { PROVIDER_COUNTRY_CURRENCY } from '../../../../functions/sendBitcoin/getPhonePaymentAddress';
import { fiatCurrencies } from '../../../../functions/currencyOptions';
export default function SendPaymentScreen(props) {
console.log('CONFIRM SEND PAYMENT SCREEN');
const navigate = useNavigation();
const {
btcAdress,
fromPage,
publishMessageFunc,
comingFromAccept,
enteredPaymentInfo = {},
errorMessage,
contactInfo,
masterTokenInfo = {},
selectedPaymentMethod = '',
preSelectedPaymentMethod,
selectedContact,
retrivedContact,
paymentDisplayCurrency,
paymentDisplayFiatStats,
} = props.route.params;
const paramsRef = useRef({
btcAdress,
});
const { poolInfoRef, swapLimits, swapUSDPriceDollars } = useFlashnet();
const { t } = useTranslation();
const { bitcoinBalance, dollarBalanceSat, dollarBalanceToken } =
useUserBalanceContext();
// Latest-value ref for balances (same pattern as stablecoinSendScreen). The
// send-decision memos/effects read balance through this ref instead of the
// reactive context values, so balance is dropped from their dependency
// arrays. A burst of incoming payments then can't recompute the payment
// method, flip uiState, or refire fee estimation mid-send. The actual send
// still validates against live balance via sparkInfoRef.current.
const balanceRef = useRef({
bitcoinBalance,
dollarBalanceSat,
dollarBalanceToken,
});
balanceRef.current.bitcoinBalance = bitcoinBalance;
balanceRef.current.dollarBalanceSat = dollarBalanceSat;
balanceRef.current.dollarBalanceToken = dollarBalanceToken;
const { sendWebViewRequest } = useWebView();
const { currentWalletMnemoinc } = useActiveCustodyAccount();
const { accountMnemoinc, contactsPrivateKey, publicKey } = useKeysContext();
const { sparkInformation, showTokensInformation, sparkInfoRef } =
useSparkWallet();
const { masterInfoObject } = useGlobalContextProvider();
const { liquidNodeInformation, fiatStats } = useNodeContext();
const { globalContactsInformation } = useGlobalContactsInfo();
const { theme, darkModeType } = useGlobalThemeContext();
const { textColor, backgroundOffset, backgroundColor } = GetThemeColors();
const { bottomPadding } = useGlobalInsets();
const didWarnAboutBudget = useRef(null);
const [isAmountFocused, setIsAmountFocused] = useState(true);
const [showProgressAnimation, setShowProgressAnimation] = useState(false);
const progressAnimationRef = useRef(null);
const hasTriggeredFastPay = useRef(false);
const convertedSendAmountRef = useRef(null);
const determinePaymentMethodRef = useRef(null);
const didRequireChoiceRef = useRef(false);
const uiStateRef = useRef(null);
const primaryDisplayRef = useRef(null);
const conversionFiatStatsRef = useRef(null);
const quoteId = useRef(null);
const isMountedRef = useRef(true);
// Tracks the decoded address we've already applied a local-currency default for,
// so the phone-payment currency default runs once per address (not every render).
const appliedLocalCurrencyRef = useRef(null);
// Drives the SWAP_RATES_CHANGED uiState when Flashnet rate drift breaks swap viability.
const [rateChangeDetected, setRateChangeDetected] = useState(false);
// Captures swapUSDPriceDollars on CONFIRM_PAYMENT entry (ref = no extra re-render).
const rateAtConfirmEntryRef = useRef(null);
const [didSelectPaymentMethod, setDidSelectPaymentMethod] = useState(false);
const [isDecoding, setIsDecoding] = useState(true);
const [paymentInfo, setPaymentInfo] = useState({});
const [lnFeeEstimate, setLnFeeEstimate] = useState(
enteredPaymentInfo?.lnFeeEstimate ?? null,
);
const [isEstimatingFee, setIsEstimatingFee] = useState(false);
const { showToast } = useToast();
const prevSelectedPaymentInfo = useRef({
preSelectedPaymentMethod,
enteredInfo: enteredPaymentInfo?.inputCurrency,
selectedPaymentMethod,
selectedLRC20Asset: masterTokenInfo?.tokenName,
});
const paymentMode =
preSelectedPaymentMethod === 'USD' ||
enteredPaymentInfo?.inputCurrency === 'USD' ||
selectedPaymentMethod === 'USD'
? 'USD'
: 'BTC';
const [paymentDescription, setPaymentDescription] = useState('');
const [loadingMessage, setLoadingMessage] = useState(
sparkInformation.didConnect
? t('wallet.sendPages.sendPaymentScreen.initialLoadingMessage')
: t('wallet.sendPages.sendPaymentScreen.connectToSparkMessage'),
);
const [refreshDecode, setRefreshDecode] = useState(0);
const isSendingPayment = useRef(null);
const userPaymentMethod = selectedPaymentMethod || preSelectedPaymentMethod;
const combinedPaymentDescription =
paymentDescription ||
paymentInfo?.data?.label ||
paymentInfo?.data?.message ||
'';
// Payment type flags
const isLightningPayment = paymentInfo?.paymentNetwork === 'lightning';
const isLiquidPayment = paymentInfo?.paymentNetwork === 'liquid';
const isBitcoinPayment = paymentInfo?.paymentNetwork === 'Bitcoin';
const isSparkPayment = paymentInfo?.paymentNetwork === 'spark';
const isLNURLPayment = paymentInfo?.type === InputTypes.LNURL_PAY;
const isUsingBranta = paymentInfo?.isUsingBranta;
const enabledLRC20 = showTokensInformation;
const defaultToken = enabledLRC20
? masterInfoObject?.defaultSpendToken || 'Bitcoin'
: 'Bitcoin';
const selectedContactInfo = contactInfo || paymentInfo?.blitzContactInfo;
// Intentionally keyed off `contactInfo` only: including blitzContactInfo here
// would swap the token selector for the BTC/USD chooser and unpin the payment
// method for @username sends. Display is handled by the InvoiceInfo props.
const useFullTokensDisplay =
enabledLRC20 &&
isSparkPayment &&
paymentInfo?.data?.expectedToken !== USDB_TOKEN_ID &&
!contactInfo;
// const showSendMax = !dollarBalanceSat && !bitcoinBalance;
// finds the true min swap amount
const min_usd_swap_amount = useMemo(() => {
return Math.round(
dollarsToSats(swapLimits.usd, poolInfoRef.currentPriceAInB),
);
}, [poolInfoRef.currentPriceAInB, swapLimits]);
const minLNURLSatAmount = isLNURLPayment
? paymentInfo?.data?.minSendable / 1000
: 0;
const maxLNURLSatAmount = isLNURLPayment
? paymentInfo?.data?.maxSendable / 1000
: 0;
// Tokens only exist on Spark — a bolt11/on-chain/LNURL invoice can never be
// funded with the default spend token. Before the decode lands paymentNetwork
// is undefined, so keep the user's pick: processSparkAddress reads it to
// decide isLRC20.
const selectedLRC20Asset =
!paymentInfo?.paymentNetwork || useFullTokensDisplay
? masterTokenInfo?.tokenName || defaultToken
: 'Bitcoin';
const seletctedToken =
masterTokenInfo?.details ||
sparkInformation?.tokens?.[selectedLRC20Asset] ||
{};
const tokenDecimals = seletctedToken?.tokenMetadata?.decimals ?? 0;
const tokenBalance = seletctedToken?.balance ?? 0;
const isUsingLRC20 = selectedLRC20Asset?.toLowerCase() !== 'bitcoin';
// The ticker actually shown on this screen. USDB-funded sends that weren't
// picked through the token selector display as fiat, so the confirm screen
// must be told the label instead of re-deriving it from the tx's token info.
const displayTokenTicker = isUsingLRC20
? seletctedToken?.tokenMetadata?.tokenTicker
: '';
const sendingAmount = paymentInfo?.sendAmount || 0;
const canEditAmount = paymentInfo?.canEditPayment === true;
const receiverExpectsCurrency = paymentInfo?.data?.expectedReceive || 'sats';
// True when the payment amount itself meets the swap minimum for this direction.
// For editable amounts or zero-amount invoices the user can change the value,
// so we treat them as viable and let downstream validation enforce the limit.
const amountViableForSwap = useMemo(() => {
if (canEditAmount || paymentInfo?.usingZeroAmountInvoice) return true;
const amountSat = Number(paymentInfo?.amountSat) || 0;
// BTC→USD swap minimum is expressed in USD, converted to sats
if (receiverExpectsCurrency === 'tokens')
return amountSat >= min_usd_swap_amount;
// USD→BTC swap minimum is expressed directly in sats
return amountSat >= swapLimits.bitcoin;
}, [
canEditAmount,
paymentInfo?.usingZeroAmountInvoice,
paymentInfo?.amountSat,
paymentInfo?.data?.expectedReceive,
min_usd_swap_amount,
swapLimits.bitcoin,
receiverExpectsCurrency,
]);
const isBTCOnlyPayment =
isBitcoinPayment ||
(isLightningPayment && paymentInfo?.usingZeroAmountInvoice);
const resolvedPaymentMethod = useMemo(() => {
// eslint-disable-next-line no-shadow -- read frozen balance, decoupled from live deps
const { bitcoinBalance, dollarBalanceSat, dollarBalanceToken } =
balanceRef.current;
if (!paymentInfo || !Object.keys(paymentInfo || {}).length)
return undefined;
if (isBitcoinPayment) return 'BTC';
if (isUsingLRC20) return 'BTC';
if (isSparkPayment && useFullTokensDisplay) return 'BTC';
if (isLightningPayment && paymentInfo?.usingZeroAmountInvoice) return 'BTC';
const flashnetReady = sparkInformation?.didConnectToFlashnet;
const receiverExpectsTokens = receiverExpectsCurrency === 'tokens';
const amountSat = paymentInfo?.amountSat || 0;
const canUse = method => {
if (method === 'USD') {
if (Number(dollarBalanceToken) < 0.01) return false;
if (receiverExpectsTokens) return dollarBalanceSat >= amountSat;
return (
flashnetReady &&
Number(dollarBalanceToken) >= swapLimits.usd &&
amountViableForSwap
);
} else {
if (!bitcoinBalance) return false;
if (receiverExpectsTokens)
return (
flashnetReady &&
bitcoinBalance >= swapLimits.bitcoin &&
amountViableForSwap
);
return bitcoinBalance >= amountSat;
}
};
const resolvePreferred = preferred => {
if (canUse(preferred)) return preferred;
const opposite = preferred === 'USD' ? 'BTC' : 'USD';
if (canUse(opposite)) return opposite;
return preferred;
};
if (preSelectedPaymentMethod)
return resolvePreferred(preSelectedPaymentMethod);
if (userPaymentMethod) return userPaymentMethod;
if (enteredPaymentInfo?.inputCurrency)
return resolvePreferred(enteredPaymentInfo.inputCurrency);
const btcViable = canUse('BTC');
const usdViable = canUse('USD');
if (btcViable && usdViable)
return receiverExpectsTokens
? dollarBalanceSat >= bitcoinBalance
? 'USD'
: 'BTC'
: bitcoinBalance >= dollarBalanceSat
? 'BTC'
: 'USD';
if (btcViable) return 'BTC';
if (usdViable) return 'USD';
return 'BTC';
}, [
preSelectedPaymentMethod,
enteredPaymentInfo?.inputCurrency,
userPaymentMethod,
paymentInfo,
isBitcoinPayment,
isUsingLRC20,
isSparkPayment,
useFullTokensDisplay,
isLightningPayment,
sparkInformation?.didConnectToFlashnet,
swapLimits,
canEditAmount,
min_usd_swap_amount,
amountViableForSwap,
receiverExpectsCurrency,
]);
const paymentFee =
resolvedPaymentMethod === 'USD' && paymentInfo?.usdPaymentFee != null
? (paymentInfo.usdPaymentFee || 0) + (paymentInfo.usdSupportFee || 0)
: (paymentInfo?.paymentFee || 0) + (paymentInfo?.supportFee || 0);
const effectivePaymentFee =
isLightningPayment && lnFeeEstimate !== null
? lnFeeEstimate + (paymentInfo?.supportFee || 0)
: paymentFee;
const usdFiatStats = useMemo(
() => resolveUsdFiatStats(fiatStats, swapUSDPriceDollars),
[fiatStats, swapUSDPriceDollars],
);
// When arriving from a flow where the user already picked a display rate
// (e.g. a contact send), honor that currency instead of re-deriving the
// device-based default. Falls back to the legacy default otherwise.
const initialDisplayCurrency = useMemo(
() =>
paymentDisplayCurrency ||
getDefaultDisplayCurrency({
paymentMode: resolvedPaymentMethod || paymentMode,
masterInfoObject,
fiatStats,
}),
[
paymentDisplayCurrency,
resolvedPaymentMethod,
paymentMode,
masterInfoObject,
fiatStats,
],
);
const additionalRates = useMemo(
() =>
paymentDisplayCurrency && paymentDisplayFiatStats?.value
? { [paymentDisplayCurrency]: paymentDisplayFiatStats }
: undefined,
[paymentDisplayCurrency, paymentDisplayFiatStats],
);
const {
displayCurrency,
currencyRates,
isLoadingRate,
selectCurrency,
loadAndSetCurrency,
injectRate,
hasUserSelectedDisplayCurrency,
} = useDisplayCurrencyController({
initialCurrency: initialDisplayCurrency,
fiatStats,
usdFiatStats,
masterInfoObject,
additionalRates,
});
const {
primaryDisplay,
secondaryDisplay,
conversionFiatStats,
convertSatsToDisplay,
convertDisplayToSats,
buildAmountSnapshot,
} = useCurrencyDisplay({
displayCurrency,
fiatStats,
usdFiatStats,
currencyRates,
masterInfoObject,
isSendingPayment: isSendingPayment.current,
});
// Prefer the user's verbatim entered string (display-truth) when we carried it across
// the accept/contact handoff and it was entered in the denomination we're now showing.
// Only genuine fixed-from-invoice amounts (no entry) fall back to reconversion.
// Guard keys on denomination only (fiat vs sats), not the specific currency: the contact
// handoff pins paymentDisplayCurrency/paymentDisplayFiatStats and the confirm view hides the
// currency switcher, so a same-denomination/different-currency mismatch cannot occur here.
const hasEnteredDisplay =
paymentInfo?.enteredDisplayAmount != null &&
paymentInfo?.enteredDisplayAmount !== '' &&
paymentInfo?.enteredDisplayDenomination === primaryDisplay.denomination;
const displayAmount = canEditAmount
? sendingAmount
: hasEnteredDisplay
? paymentInfo.enteredDisplayAmount
: convertSatsToDisplay(sendingAmount);
const convertedSendAmount = !isUsingLRC20
? canEditAmount
? convertDisplayToSats(sendingAmount) // User entered amount, convert to sats
: Number(sendingAmount) // Fixed invoice amount, already in sats
: Number(sendingAmount);
// use stablepool info ref so the fiat amount doesnt change in between inputs leading to a false amount being sent.
const fiatValueConvertedSendAmount = Math.round(
satsToDollars(
convertedSendAmount,
enteredPaymentInfo?.stablePoolInfoRef?.currentPriceAInB ||
poolInfoRef.currentPriceAInB,
).toFixed(2) * Math.pow(10, 6),
);
const requiresUserMethodSelection = useMemo(() => {
// eslint-disable-next-line no-shadow -- read frozen balance, decoupled from live deps
const { bitcoinBalance, dollarBalanceSat } = balanceRef.current;
if (resolvedPaymentMethod === undefined) return false;
if (
!!preSelectedPaymentMethod ||
useFullTokensDisplay ||
isUsingLRC20 ||
didSelectPaymentMethod
)
return false;
if (!sparkInformation?.didConnectToFlashnet) return false;
if (isBitcoinPayment) return false;
if (isLightningPayment && paymentInfo?.usingZeroAmountInvoice) return false;
const formattedFee = isLightningPayment ? effectivePaymentFee : 0;
const hasEnoughBTC = bitcoinBalance >= convertedSendAmount + formattedFee;
const hasEnoughUSD = dollarBalanceSat >= convertedSendAmount + formattedFee;
const canSendWithBTC =
resolvedPaymentMethod === 'BTC' && receiverExpectsCurrency === 'sats'
? hasEnoughBTC
: hasEnoughBTC && amountViableForSwap;
const canSendWIthUSD =
resolvedPaymentMethod === 'USD' && receiverExpectsCurrency === 'tokens'
? hasEnoughUSD
: hasEnoughUSD && amountViableForSwap;
if (
(canSendWithBTC && canSendWIthUSD) ||
(!canSendWithBTC && !canSendWIthUSD)
)
return true;
else return false;
}, [
isBitcoinPayment,
useFullTokensDisplay,
isUsingLRC20,
didSelectPaymentMethod,
sparkInformation?.didConnectToFlashnet,
paymentInfo?.data?.expectedReceive,
amountViableForSwap,
resolvedPaymentMethod,
receiverExpectsCurrency,
preSelectedPaymentMethod,
isLightningPayment,
]);
const { shouldWarn } = useBudgetWarning(convertedSendAmount);
useEffect(() => {
primaryDisplayRef.current = primaryDisplay;
}, [primaryDisplay]);
useEffect(() => {
conversionFiatStatsRef.current = conversionFiatStats;
}, [conversionFiatStats]);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
determinePaymentMethodRef.current = resolvedPaymentMethod;
}, [resolvedPaymentMethod]);
const estimateLightningFee = useCallback(
async (amount, id) => {
// eslint-disable-next-line no-shadow -- read frozen balance, decoupled from live deps
const { bitcoinBalance, dollarBalanceSat, dollarBalanceToken } =
balanceRef.current;
if (!amount || !isLightningPayment || !canEditAmount) {
setIsEstimatingFee(false);
return;
}
if (quoteId.current !== id) return;
const balance =
resolvedPaymentMethod === 'USD' ? dollarBalanceSat : bitcoinBalance;
const bufferAmount = amount * 1.1;
// Skip if balance easily covers the send + estimated fee buffer, or if
// already over balance (validation will catch it without needing a fee)
if (bufferAmount < balance || amount > balance) {
setIsEstimatingFee(false);
return;
}
try {
const formattedSparkPaymentInfo = formatSparkPaymentAddress(
paymentInfo,
false,
);
let invoice = formattedSparkPaymentInfo.address;
if (paymentInfo.type === InputTypes.LNURL_PAY && !invoice) {
const invoiceResponse = await getLNAddressForLiquidPayment(
paymentInfo.decodedInput,
amount,
);
if (!invoiceResponse.pr) throw new Error('No invoice received');
invoice = invoiceResponse.pr;
setPaymentInfo(prev => ({
...prev,
data: { ...(prev.data || {}), invoice },
}));
}
if (!invoice) {
setIsEstimatingFee(false);
return;
}
if (resolvedPaymentMethod === 'USD') {
const quote = await getLightningPaymentQuote(
currentWalletMnemoinc,
invoice,
USD_ASSET_ADDRESS,
undefined,
undefined,
{ amountSats: amount },
);
if (!quote.didWork)
throw new Error(quote.error || 'Fee quote failed');
if (quoteId.current !== id) return;
const estimatedAmmFeeSat = Math.round(
dollarsToSats(
quote.quote.estimatedAmmFee / Math.pow(10, 6),
poolInfoRef.currentPriceAInB,
),
);
const dollarAmountRequired = quote.quote.tokenAmountRequired;
const userDollarBalance = dollarBalanceToken * Math.pow(10, 6);
const fee = quote.quote.estimatedLightningFee + estimatedAmmFeeSat;
if (dollarAmountRequired > userDollarBalance) {
showToast({
type: 'error',
title: t('errormessages.lightningAmountRequiredWarning', {
amount: displayCorrectDenomination({
amount: parseFloat(
formatTokensNumber(quote.quote.tokenAmountRequired, 6),
).toFixed(2),
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: 'fiat',
},
fiatStats,
convertAmount: false,
forceCurrency: 'USD',
}),
}),
duration: 6000,
});
}
setLnFeeEstimate(fee);
setPaymentInfo(prev => ({
...prev,
paymentFee: fee,
supportFee: prev.supportFee ?? 0,
swapPaymentQuote: {
...quote.quote,
bitcoinBalance,
dollarBalanceSat,
},
}));
} else {
const feeResult = await sparkPaymenWrapper({
getFee: true,
paymentType: 'lightning',
address: invoice,
amountSats: amount,
masterInfoObject,
sparkInformation: sparkInfoRef.current,
mnemonic: currentWalletMnemoinc,
sendWebViewRequest,
});
if (!feeResult.didWork) throw new Error('Fee estimation failed');
if (quoteId.current !== id) return;
const fee = feeResult.fee;
if (fee + amount > bitcoinBalance) {
showToast({
type: 'error',
title: t('errormessages.lightningAmountFeeWarning', {
amount: displayCorrectDenomination({
amount: fee,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: 'sats',
},
fiatStats,
}),
}),
duration: 6000,
});
}
setLnFeeEstimate(fee);
setPaymentInfo(prev => ({
...prev,
paymentFee: fee,
supportFee: prev.supportFee ?? 0,
}));
}
} catch {
showToast({
type: 'error',
title: t('wallet.sendPages.sendPaymentScreen.feeEstimateError'),
});
} finally {
if (quoteId.current === id) {
setIsEstimatingFee(false);
}
}
},
[
isLightningPayment,
canEditAmount,
resolvedPaymentMethod,
paymentInfo,
currentWalletMnemoinc,
masterInfoObject,
fiatStats,
sparkInfoRef,
sendWebViewRequest,
showToast,
t,
],
);
const debouncedEstimateFee = useDebounce(estimateLightningFee, 600);
useEffect(() => {
if (requiresUserMethodSelection && !didRequireChoiceRef.current) {
didRequireChoiceRef.current = true;
}
}, [requiresUserMethodSelection]);
// Fast pay logic
const canUseFastPay =
sparkInformation.didConnect &&
Object.keys(paymentInfo || {}).length > 0 &&
masterInfoObject[QUICK_PAY_STORAGE_KEY]?.isFastPayEnabled &&
masterInfoObject[QUICK_PAY_STORAGE_KEY]?.fastPayThresholdSats >=
convertedSendAmount &&
!isUsingLRC20 &&
(!didRequireChoiceRef.current || didSelectPaymentMethod) &&
!requiresUserMethodSelection &&
convertedSendAmount >= effectivePaymentFee &&
!shouldWarn;
const uiState = useMemo(() => {
if (canEditAmount && !isSendingPayment.current) {
return 'EDIT_AMOUNT'; // Show number pad + description input
}
// Rate-change intercept: show before CHOOSE_METHOD / CONFIRM_PAYMENT so it
// takes over the screen whenever the Flashnet rate broke swap viability.
if (rateChangeDetected) {
return 'SWAP_RATES_CHANGED';
}
if (
requiresUserMethodSelection &&
!isSendingPayment.current &&
!isBitcoinPayment &&
!isUsingLRC20 &&
!preSelectedPaymentMethod
) {
return 'CHOOSE_METHOD'; // Show info screen with button to select method
}
return 'CONFIRM_PAYMENT'; // Show swipe button
}, [
canEditAmount,
rateChangeDetected,
requiresUserMethodSelection,
didSelectPaymentMethod,
isBitcoinPayment,
isUsingLRC20,
canUseFastPay,
preSelectedPaymentMethod,
]);
useEffect(() => {
uiStateRef.current = uiState;
}, [uiState]);
useEffect(() => {
if (
uiState === 'CONFIRM_PAYMENT' &&
shouldWarn &&
!didWarnAboutBudget.current &&
!isSendingPayment.current &&
!isDecoding
) {
didWarnAboutBudget.current = true;
navigate.navigate('CustomHalfModal', {
wantedContent: 'nearBudgetLimitWarning',
sliderHight: 0.6,
sendingAmount: convertedSendAmount,
});
}
}, [uiState, shouldWarn, convertedSendAmount, isDecoding]);
useEffect(() => {
if (
prevSelectedPaymentInfo.current.preSelectedPaymentMethod !==
preSelectedPaymentMethod ||
prevSelectedPaymentInfo.current.enteredInfo !==
enteredPaymentInfo?.inputCurrency ||
prevSelectedPaymentInfo.current.selectedPaymentMethod !==
selectedPaymentMethod ||
prevSelectedPaymentInfo.current.selectedLRC20Asset !== selectedLRC20Asset
) {
console.log(
'Payment method or input currency changed, resetting payment info',
);
if (uiStateRef.current !== 'EDIT_AMOUNT') return;
console.log('Resetting payment info for new selection');
setPaymentInfo(prev => ({
...prev,
sendAmount: '',
feeQuote: undefined,
swapPaymentQuote: undefined,
paymentFee: 0,
supportFee: 0,
}));
prevSelectedPaymentInfo.current = {
preSelectedPaymentMethod,
enteredInfo: enteredPaymentInfo?.inputCurrency,
selectedPaymentMethod,
selectedLRC20Asset,
};
}
}, [
preSelectedPaymentMethod,
enteredPaymentInfo?.inputCurrency,
selectedPaymentMethod,
selectedLRC20Asset,
]);
const paymentValidation = usePaymentValidation({
paymentInfo,
convertedSendAmount,
paymentFee: effectivePaymentFee,
determinePaymentMethod: resolvedPaymentMethod,
selectedPaymentMethod: userPaymentMethod,
bitcoinBalance,
dollarBalanceSat,
dollarBalanceToken,
min_usd_swap_amount,
swapLimits,
isUsingLRC20,
seletctedToken,
minLNURLSatAmount,
maxLNURLSatAmount,
isDecoding,
canEditAmount,
t,
masterInfoObject,
fiatStats,
inputDenomination: primaryDisplay.denomination,
primaryDisplay,
conversionFiatStats,
sparkInformation,
poolInfoRef,
});
console.log(paymentValidation);
const canSendPayment =
paymentValidation.canProceed &&
sendingAmount !== 0 &&
uiState === 'CONFIRM_PAYMENT';
const isUsingFastPay = canUseFastPay && canSendPayment && !canEditAmount;
// Rate-sensitive swap path: only Flashnet swaps (USD→BTC / BTC→USDB) are affected
// by live rate changes. Mirrors needsSwap in paymentValidation.
const needsRateSwap =
(resolvedPaymentMethod === 'USD' && receiverExpectsCurrency === 'sats') ||
(resolvedPaymentMethod === 'BTC' && receiverExpectsCurrency === 'tokens');
// Snapshot + detection effect.
// Captures the rate on CONFIRM_PAYMENT entry; when a subsequent rate tick
// breaks swap viability (convertedSendAmount shrinks, min_usd_swap_amount rises),
// sets rateChangeDetected → transitions to SWAP_RATES_CHANGED uiState.
// Resets completely when the user leaves the confirm flow.
useEffect(() => {
if (isDecoding) return;
if (uiState === 'CONFIRM_PAYMENT') {
// Capture rate once on entry
if (rateAtConfirmEntryRef.current === null) {
rateAtConfirmEntryRef.current = swapUSDPriceDollars;
}
// Detect drift that broke viability
if (
rateAtConfirmEntryRef.current !== null &&
swapUSDPriceDollars !== rateAtConfirmEntryRef.current &&
!paymentValidation.canProceed &&
needsRateSwap &&
!isSendingPayment.current
) {
setRateChangeDetected(true);
}
} else if (uiState !== 'SWAP_RATES_CHANGED') {
// Leaving the confirm flow entirely (EDIT / CHOOSE / back) — full reset.
// Guard against SWAP_RATES_CHANGED so the state doesn't clear itself.
rateAtConfirmEntryRef.current = null;
setRateChangeDetected(false);
}
}, [
uiState,
swapUSDPriceDollars,
paymentValidation.canProceed,
needsRateSwap,
isDecoding,
]);
const handleRateChangedReset = useCallback(() => {
rateAtConfirmEntryRef.current = null;
setRateChangeDetected(false);
if (isLNURLPayment || isSparkPayment) {
// Full reset — mirrors hasParamsChanged effect sequence exactly
setIsAmountFocused(true);
setPaymentInfo({});
setLnFeeEstimate(null);
setIsEstimatingFee(false);
isSendingPayment.current = null;
setPaymentDescription('');
hasTriggeredFastPay.current = false;
didRequireChoiceRef.current = false;
setLoadingMessage(
sparkInformation.didConnect && sparkInformation.identityPubKey
? t('wallet.sendPages.sendPaymentScreen.initialLoadingMessage')
: t('wallet.sendPages.sendPaymentScreen.connectToSparkMessage'),
);
setDidSelectPaymentMethod(false);
setShowProgressAnimation(false);
setRefreshDecode(x => x + 1);
} else {
navigate.goBack();
}
}, [
isLNURLPayment,
isSparkPayment,
navigate,
sparkInformation.didConnect,
sparkInformation.identityPubKey,
t,
]);
const errorMessageNavigation = useCallback(
reason => {
if (!isMountedRef.current) return;
navigate.navigate('ConfirmPaymentScreen', {
btcAdress: '',
fromPage: '',
publishMessageFunc: null,
comingFromAccept: null,
enteredPaymentInfo: {},
errorMessage:
reason ||
t('wallet.sendPages.sendPaymentScreen.fallbackErrorMessage'),
});
},
[navigate, t],
);
useEffect(() => {
const currentParams = { btcAdress };
const prevParams = paramsRef.current;
const hasParamsChanged =
currentParams.btcAdress &&
currentParams.btcAdress !== prevParams.btcAdress;
if (hasParamsChanged) {
setIsAmountFocused(true);
setPaymentInfo({});
setLnFeeEstimate(null);
setIsEstimatingFee(false);
isSendingPayment.current = null;
setPaymentDescription('');
hasTriggeredFastPay.current = false;
didRequireChoiceRef.current = false;
setLoadingMessage(
!!sparkInformation.didConnect && !!sparkInformation.identityPubKey
? t('wallet.sendPages.sendPaymentScreen.initialLoadingMessage')
: t('wallet.sendPages.sendPaymentScreen.connectToSparkMessage'),
);
setDidSelectPaymentMethod(false);
setShowProgressAnimation(false);
setRefreshDecode(x => x + 1);
paramsRef.current = currentParams;
}
}, [
btcAdress,
sparkInformation.didConnect,
sparkInformation.identityPubKey,
t,
]);
useEffect(() => {
convertedSendAmountRef.current = convertedSendAmount;
}, [convertedSendAmount]);
useEffect(() => {
if (!canEditAmount || !isLightningPayment) return;
setLnFeeEstimate(null);
setPaymentInfo(prev => ({
...prev,
data: { ...(prev.data || {}), invoice: '' },
paymentFee: 0,
}));
if (convertedSendAmount > 0) {
const id = customUUID();
quoteId.current = id;
setIsEstimatingFee(true);
debouncedEstimateFee(convertedSendAmount, id);
}
}, [convertedSendAmount, canEditAmount, isLightningPayment]);
useEffect(() => {
async function decodePayment() {
crashlyticsLogReport('Starting decode address');
setIsDecoding(true);
await decodeSendAddress({
fiatStats,
btcAdress: paramsRef.current.btcAdress,
goBackFunction: errorMessageNavigation,
setPaymentInfo,
liquidNodeInformation,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination:
primaryDisplayRef.current?.denomination || 'sats',
},
// setWebViewArgs,
// webViewRef,
navigate,
// maxZeroConf:
// minMaxLiquidSwapAmounts?.submarineSwapStats?.limits?.maximalZeroConf,
comingFromAccept: enteredPaymentInfo.fromContacts,
enteredPaymentInfo,
setLoadingMessage,
paymentInfo,
fromPage,
sparkInformation,
seletctedToken,
currentWalletMnemoinc,
t,
sendWebViewRequest,
contactInfo,
globalContactsInformation,
accountMnemoinc,
usablePaymentMethod:
userPaymentMethod || determinePaymentMethodRef.current,
bitcoinBalance,
dollarBalanceSat,
convertedSendAmount: convertedSendAmountRef.current,
poolInfoRef,
swapLimits,
// usd_multiplier_coefiicent,
min_usd_swap_amount,
primaryDisplay: primaryDisplayRef.current,
conversionFiatStats: conversionFiatStatsRef.current,
contactsPrivateKey,
publicKey,
});
setIsDecoding(false);
}
if (!sparkInformation.didConnect || !sparkInformation.identityPubKey)
return;
if (isSendingPayment.current) return;
requestAnimationFrame(() => {
decodePayment();
});
}, [
sparkInformation.didConnect,
sparkInformation.identityPubKey,
refreshDecode,
// selectedPaymentMethod,
]);
// After an LNURL pay decode, default the amount input to the payment location's
// local fiat currency for phone-number payments, and seed any LNURL-advertised
// rate so a manual switch to that currency uses the provided rate. Runs once per
// decoded address and never overrides a manual currency selection.
useEffect(() => {
const country = paymentInfo?.phonePaymentCountry;
const descriptor = paymentInfo?.lnurlCurrency || null;
// Nothing to do for non-phone pays that don't advertise a currency.
if (paymentDisplayCurrency && paymentDisplayFiatStats) return;
if (!country && !descriptor) return;
if (hasUserSelectedDisplayCurrency) return;
const address = paymentInfo?.data?.address;
if (appliedLocalCurrencyRef.current === address) return;
const code = (
descriptor?.code ||
(country ? PROVIDER_COUNTRY_CURRENCY[country] : '') ||
''
).toUpperCase();
if (!code || !fiatCurrencies.some(c => c.id === code)) return;
appliedLocalCurrencyRef.current = address;
const rate = lnurlCurrencyToRate(descriptor);
if (rate) {
// Phone pay: seed the rate and switch to it. Non-phone: seed only, so the
// provided rate is used if the user manually switches to this currency.
injectRate(code, rate, { setAsDisplay: !!country });
} else if (country) {
// Phone pay with no usable LNURL rate: fall back to our internal rate. If
// that fails, the existing BTC/device-currency default stays.
loadAndSetCurrency(code);
}
}, [
paymentInfo,
hasUserSelectedDisplayCurrency,
injectRate,
loadAndSetCurrency,
]);
// Fast pay auto-trigger
useEffect(() => {
if (
!isUsingFastPay ||
hasTriggeredFastPay.current ||
isSendingPayment.current
)
return;
setShowProgressAnimation(true);
if (progressAnimationRef.current) {
requestAnimationFrame(() => {
progressAnimationRef.current.startAtBeginning();
});
}
const fastPayTrigger = setTimeout(() => {
hasTriggeredFastPay.current = true;
if (progressAnimationRef.current) {
progressAnimationRef.current.startProgress();
}
sendPayment();
}, 250);
return () => {
clearTimeout(fastPayTrigger);
};
}, [isUsingFastPay]);
const publishMessageFuncForContact = useCallback(
txid => {
const UUID = customUUID();
const sendObject = {
amountMsat: convertedSendAmountRef.current * 1000,
uuid: UUID,
wasSeen: null,
didSend: null,
isRedeemed: null,
description: combinedPaymentDescription || '',
isRequest: false,
paymentDenomination:
paymentInfo.data.expectedReceive === 'tokens' ? 'USD' : 'BTC',
amountDollars:
paymentInfo.data.expectedReceive === 'tokens'
? satsToDollars(
convertedSendAmount,
poolInfoRef.currentPriceAInB,
).toFixed(2)
: null,
...(globalContactsInformation.myProfile?.uniqueName
? {
senderProfileSnapshot: {
uniqueName: globalContactsInformation.myProfile.uniqueName,
},
}
: {}),
};
publishMessage({
toPubKey: selectedContact.uuid,
fromPubKey: globalContactsInformation.myProfile.uuid,
data: {
...sendObject,
txid,
name:
globalContactsInformation.myProfile?.name ||
globalContactsInformation.myProfile?.uniqueName,
},
globalContactsInformation,
selectedContact,
isLNURLPayment: selectedContact?.isLNURL,
privateKey: contactsPrivateKey,
retrivedContact,
currentTime: Date.now(),
masterInfoObject,
});
},
[
selectedContact,
retrivedContact,
globalContactsInformation,
contactsPrivateKey,
masterInfoObject,
combinedPaymentDescription,
paymentInfo,
],
);
const effectivePublishMessageFunc =
paymentInfo?.publishMessageFunc ||
publishMessageFunc ||
(selectedContact ? publishMessageFuncForContact : null);
const sendPayment = useCallback(async () => {
if (!paymentValidation.isValid) {
const error = paymentValidation.getErrorMessage(
paymentValidation.primaryError,
);
navigate.navigate('ErrorScreen', { errorMessage: error });
return;
}
if (isSendingPayment.current) return;
isSendingPayment.current = true;
setShowProgressAnimation(true);
try {
const formattedSparkPaymentInfo = formatSparkPaymentAddress(
paymentInfo,
selectedLRC20Asset?.toLowerCase() !== 'bitcoin',
);
const memo =
paymentInfo.type === InputTypes.BOLT11
? enteredPaymentInfo?.description || combinedPaymentDescription
: combinedPaymentDescription || enteredPaymentInfo?.description;
const paymentObject = {
getFee: false,
...formattedSparkPaymentInfo,
isUsingLRC20,
amountSats: isUsingLRC20
? paymentInfo?.sendAmount * 10 ** tokenDecimals
: convertedSendAmount,
masterInfoObject,
fee: effectivePaymentFee,
memo,
userBalance: bitcoinBalance,
sparkInformation: sparkInfoRef.current,
feeQuote: paymentInfo.feeQuote,
swapPaymentQuote: paymentInfo.swapPaymentQuote,
usingZeroAmountInvoice: paymentInfo.usingZeroAmountInvoice,
seletctedToken: selectedLRC20Asset,
mnemonic: currentWalletMnemoinc,
sendWebViewRequest,
contactInfo,
fromMainSendScreen: true,
usablePaymentMethod: resolvedPaymentMethod,
paymentInfo,
fiatValueConvertedSendAmount,
poolInfoRef,
extraDetails: paymentInfo?.isUsingBranta
? {
brantaMerchantName: paymentInfo.brantaMerchantName,
brantaMerchantLogo: paymentInfo.brantaMerchantLogo,
}
: {},
};
const paymentResponse = await sparkPaymenWrapper(paymentObject);
// Handle deferred save if identityPubKey wasn't available during payment
if (paymentResponse.shouldSave) {
let retries = 0;
const maxRetries = 20; // 10 seconds max wait
while (!sparkInfoRef.current.identityPubKey && retries < maxRetries) {
console.log('WATIINT FOR IDENTITY PUBKEY');
await new Promise(res => setTimeout(res, 500));
retries++;
}
if (sparkInfoRef.current.identityPubKey) {
const tx = {
...paymentResponse.response,
accountId: sparkInfoRef.current.identityPubKey,
};
await bulkUpdateSparkTransactions([tx], 'paymentWrapperTx', 0);
} else {
console.error('Failed to get identityPubKey after waiting');
}
isSendingPayingEventEmiiter.emit(SENDING_PAYMENT_EVENT_NAME, false);
}
if (progressAnimationRef.current) {
progressAnimationRef.current.completeProgress();
await new Promise(res => setTimeout(res, 600));
}
if (paymentResponse.didWork) {
if (
(fromPage === 'contacts' && paymentResponse.response?.id) ||
fromPage === 'paylink'
) {
effectivePublishMessageFunc(paymentResponse.response.id);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
navigate.reset({
index: 0,
routes: [
{
name: 'HomeAdmin',
params: {
screen: 'Home',
},
},
{
name: 'ConfirmTxPage',
params: {
transaction: paymentResponse.response,
lnurlAddress:
paymentInfo?.type === InputTypes.LNURL_PAY
? normalizeLNURLAddress(paymentInfo?.data?.address)
: undefined,
blitzContactInfo: selectedContactInfo,
paymentDisplay: primaryDisplayRef.current,
displayAmount,
displayTokenTicker,
},
},
],
});
});
});
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
navigate.reset({
index: 0,
routes: [
{
name: 'HomeAdmin',
params: {
screen: 'Home',
},
},
{
name: 'ConfirmTxPage',
params: {
transaction: paymentResponse.response,
error: paymentResponse.error,
lnurlAddress:
paymentInfo?.type === InputTypes.LNURL_PAY
? normalizeLNURLAddress(paymentInfo?.data?.address)
: undefined,
blitzContactInfo: selectedContactInfo,
paymentDisplay: primaryDisplayRef.current,
displayAmount,
displayTokenTicker,
},
},
],
});
});
});
}
} catch (error) {
console.error('Error in sendPayment:', error);
// Reset state on error
isSendingPayment.current = false;
setShowProgressAnimation(false);
// Optionally show error to user
errorMessageNavigation(error.message);
}
}, [
canSendPayment,
paymentInfo,
selectedLRC20Asset,
enteredPaymentInfo,
combinedPaymentDescription,
isUsingLRC20,
tokenDecimals,
convertedSendAmount,
masterInfoObject,
effectivePaymentFee,
bitcoinBalance,
sparkInformation,
currentWalletMnemoinc,
sendWebViewRequest,
contactInfo,
fromPage,
effectivePublishMessageFunc,
navigate,
errorMessageNavigation,
resolvedPaymentMethod,
fiatValueConvertedSendAmount,
paymentValidation,
displayAmount,
displayTokenTicker,
selectedContactInfo,
]);
const handleSelectPaymentMethod = useCallback(
showNextScreen => {
if (showNextScreen) {
if (!paymentValidation.isValid) {
const error = paymentValidation.getErrorMessage(
paymentValidation.primaryError,
);
navigate.navigate('ErrorScreen', { errorMessage: error });
return;
}
setDidSelectPaymentMethod(true);
} else {
navigate.navigate('CustomHalfModal', {
wantedContent: 'SelectPaymentMethod',
selectedPaymentMethod: resolvedPaymentMethod,
});
}
},
[navigate, paymentValidation, resolvedPaymentMethod],
);
const handleSelectTokenPress = useCallback(() => {
navigate.navigate('CustomHalfModal', {
wantedContent: 'SelectLRC20Token',
});
}, [navigate]);
const handleEmoji = newDescription => {
setPaymentDescription(newDescription);
};
const openCurrencyPicker = () => {
if (!isAmountFocused) return;
if (isUsingLRC20) return;
if (isSendingPayment.current) return;
navigate.navigate('CustomHalfModal', {
wantedContent: 'displayCurrencySelect',
sliderHight: 0.6,
currentCurrency: displayCurrency,
onSelectCurrency: async code => {
const response = await selectCurrency(code);
if (!response?.didWork) return response;
if (canEditAmount) {
setPaymentInfo(prev => ({
...prev,
sendAmount: '',
feeQuote: undefined,
swapPaymentQuote: undefined,
paymentFee: 0,
supportFee: 0,
}));
}
return response;
},
});
};
const memorizedKeyboardStyle = useMemo(() => {
return {
paddingBottom: !isAmountFocused ? 0 : bottomPadding,
};
}, [isAmountFocused, bottomPadding]);
const handleBrandaVerificationUrl = useCallback(() => {
navigate.navigate('CustomWebView', {
headerText: 'Branta',
webViewURL: paymentInfo?.verificationURL,
});
}, [paymentInfo?.verificationURL, navigate, t]);
if (
(!Object.keys(paymentInfo).length && !errorMessage) ||
!sparkInformation.didConnect
) {
return (
<GlobalThemeView useStandardWidth={true}>
<CustomSettingsTopBar />
<FullLoadingScreen text={loadingMessage} />
</GlobalThemeView>
);
}
if (errorMessage) {
return <ErrorWithPayment reason={errorMessage} />;
}
return (
<CustomKeyboardAvoidingView globalThemeViewStyles={memorizedKeyboardStyle}>
<View style={styles.replacementContainer}>
<CustomSettingsTopBar
label={t('constants.send')}
showLeftImage={isUsingBranta}
iconNew="BadgeCheck"
leftImageStyles={{ height: 25 }}
leftImageFunction={handleBrandaVerificationUrl}
rightContent={
!isUsingLRC20 && uiState !== 'CONFIRM_PAYMENT' ? (
<CurrencySwitchButton
displayCurrency={displayCurrency}
onPress={openCurrencyPicker}
disabled={isLoadingRate}
/>
) : null
}
/>
<ScrollView contentContainerStyle={styles.balanceScrollContainer}>
{/* Amount display */}
{uiState !== 'SWAP_RATES_CHANGED' && (
<View>
<FormattedBalanceInput
maxWidth={0.9}
amountValue={displayAmount}
inputDenomination={primaryDisplay.denomination}
forceCurrency={primaryDisplay.forceCurrency}
forceFiatStats={primaryDisplay.forceFiatStats}
activeOpacity={!sendingAmount ? 0.5 : 1}
customCurrencyCode={displayTokenTicker}
maxDecimals={isUsingLRC20 ? tokenDecimals : 2}
/>
{uiState === 'CONFIRM_PAYMENT' && !isUsingLRC20 && (
<SecondaryAmountDisplay
amountSats={convertedSendAmount}
secondaryDisplay={secondaryDisplay}
/>
)}
</View>
)}
{/* Send max button for edit mode */}
{/* {!useAltLayout && uiState === 'EDIT_AMOUNT' && showSendMax && (
<SendMaxComponent
fiatStats={fiatStats}
sparkInformation={sparkInformation}
paymentInfo={paymentInfo}
setPaymentInfo={setPaymentInfo}
masterInfoObject={masterInfoObject}
paymentFee={paymentFee}
paymentType={paymentInfo?.paymentNetwork}
// minMaxLiquidSwapAmounts={minMaxLiquidSwapAmounts}
selectedLRC20Asset={selectedLRC20Asset}
seletctedToken={seletctedToken}
useAltLayout={useAltLayout}
/>
)} */}
{/* Fee info for fixed amount */}
{uiState === 'CONFIRM_PAYMENT' && (
<SendTransactionFeeInfo
paymentFee={effectivePaymentFee}
isLightningPayment={isLightningPayment}
isLiquidPayment={isLiquidPayment}
isBitcoinPayment={isBitcoinPayment}
isSparkPayment={isSparkPayment}
isDecoding={isDecoding}
display={primaryDisplay}
/>
)}
{/* Invoice info */}
{uiState === 'CONFIRM_PAYMENT' && (
<InvoiceInfo
paymentInfo={paymentInfo}
contactInfo={selectedContactInfo}
fromPage={fromPage || (selectedContactInfo ? 'contacts' : '')}
theme={theme}
darkModeType={darkModeType}
isUsingBranta={isUsingBranta}
isDollarBalance={
resolvedPaymentMethod === 'USD' ||
selectedLRC20Asset === USDB_TOKEN_ID
}
isToken={isUsingLRC20 && selectedLRC20Asset !== USDB_TOKEN_ID}
/>
)}
{uiState === 'CHOOSE_METHOD' && (
<ChoosePaymentMethod
theme={theme}
darkModeType={darkModeType}
determinePaymentMethod={resolvedPaymentMethod}
handleSelectPaymentMethod={handleSelectPaymentMethod}
bitcoinBalance={bitcoinBalance}
dollarBalanceToken={dollarBalanceToken}
masterInfoObject={masterInfoObject}
fiatStats={fiatStats}
uiState={uiState}
t={t}
showPayWith={true}
/>
)}
{/* SWAP_RATES_CHANGED — rate drifted and broke swap viability */}
{uiState === 'SWAP_RATES_CHANGED' && <SwapRatesChangedState />}
</ScrollView>
{/* EDIT_AMOUNT State - Show input controls */}
{uiState === 'EDIT_AMOUNT' && (
<>
{/* Token selector for Spark LRC20 payments */}
{enabledLRC20 &&
paymentInfo.type === 'spark' &&
canEditAmount &&
useFullTokensDisplay && (
<ChooseLRC20TokenContainer
theme={theme}
darkModeType={darkModeType}
determinePaymentMethod={resolvedPaymentMethod}
handleSelectPaymentMethod={handleSelectTokenPress}
bitcoinBalance={bitcoinBalance}
dollarBalanceToken={dollarBalanceToken}
masterInfoObject={masterInfoObject}
fiatStats={fiatStats}
uiState={uiState}
seletctedToken={seletctedToken}
selectedLRC20Asset={selectedLRC20Asset}
t={t}
/>
)}
{!(
enabledLRC20 &&
paymentInfo.type === 'spark' &&
canEditAmount &&
useFullTokensDisplay
) && (
<ChoosePaymentMethod
theme={theme}
darkModeType={darkModeType}
determinePaymentMethod={resolvedPaymentMethod}
handleSelectPaymentMethod={handleSelectPaymentMethod}
bitcoinBalance={bitcoinBalance}
dollarBalanceToken={dollarBalanceToken}
masterInfoObject={masterInfoObject}
fiatStats={fiatStats}
uiState={uiState}
t={t}
showBitcoinCardOnly={isBTCOnlyPayment}
showPayWith={true}
/>
)}
<CustomSearchInput
onFocusFunction={() => setIsAmountFocused(false)}
onBlurFunction={() => setIsAmountFocused(true)}
placeholderText={t('constants.paymentDescriptionPlaceholder')}
setInputText={setPaymentDescription}
inputText={combinedPaymentDescription}
textInputMultiline={true}
textAlignVertical={'baseline'}
maxLength={paymentInfo?.data?.commentAllowed || 150}
containerStyles={{
width: INSET_WINDOW_WIDTH,
marginTop: 10,
}}
/>
{isAmountFocused && (
<NumberInputSendPage
paymentInfo={paymentInfo}
setPaymentInfo={setPaymentInfo}
fiatStats={conversionFiatStats}
selectedLRC20Asset={selectedLRC20Asset}
seletctedToken={seletctedToken}
inputDenomination={primaryDisplay.denomination}
primaryDisplay={primaryDisplay}
/>
)}
{
// !useAltLayout &&
isAmountFocused && (
<AcceptButtonSendPage
decodeSendAddress={decodeSendAddress}
errorMessageNavigation={errorMessageNavigation}
btcAdress={btcAdress}
paymentInfo={paymentInfo}
convertedSendAmount={convertedSendAmount}
paymentDescription={combinedPaymentDescription}
setPaymentInfo={setPaymentInfo}
setLoadingMessage={setLoadingMessage}
fromPage={fromPage}
sparkInformation={sparkInformation}
seletctedToken={seletctedToken}
useAltLayout={false} //will be useAltLayout in future
sendWebViewRequest={sendWebViewRequest}
globalContactsInformation={globalContactsInformation}
canUseFastPay={canUseFastPay}
selectedPaymentMethod={resolvedPaymentMethod}
bitcoinBalance={bitcoinBalance}
dollarBalanceSat={dollarBalanceSat}
isDecoding={isDecoding || isEstimatingFee}
poolInfoRef={poolInfoRef}
swapLimits={swapLimits}
min_usd_swap_amount={min_usd_swap_amount}
inputDenomination={primaryDisplay.denomination}
paymentValidation={paymentValidation}
setDidSelectPaymentMethod={setDidSelectPaymentMethod}
conversionFiatStats={conversionFiatStats}
primaryDisplay={primaryDisplay}
enteredPaymentInfo={enteredPaymentInfo}
enteredDisplaySnapshot={buildAmountSnapshot(sendingAmount)}
/>
)
}
</>
)}
{/* SELECT_PAYMENT method state - show button to take user to select half modal */}
{uiState === 'CHOOSE_METHOD' && (
<CustomButton
buttonStyles={{
...CENTER,
width: INSET_WINDOW_WIDTH,
opacity: paymentValidation.isValid ? 1 : HIDDEN_OPACITY,
}}
actionFunction={() => handleSelectPaymentMethod(true)}
textContent={t('constants.review')}
/>
)}
{/* SWAP_RATES_CHANGED — primary CTA to re-enter amount */}
{uiState === 'SWAP_RATES_CHANGED' && (
<CustomButton
buttonStyles={{
width: INSET_WINDOW_WIDTH,
...CENTER,
}}
actionFunction={handleRateChangedReset}
textContent={t(
'wallet.sendPages.sendPaymentScreen.swapRatesChangedButton',
)}
/>
)}
{/* CONFIRM_PAYMENT State - Show swipe button or progress animation */}
{uiState === 'CONFIRM_PAYMENT' && (
<View style={styles.buttonContainer}>
{showProgressAnimation || isUsingFastPay ? (
<SliderProgressAnimation
ref={progressAnimationRef}
isVisible={showProgressAnimation || isUsingFastPay}
textColor={COLORS.darkModeText}
backgroundColor={
theme && darkModeType ? backgroundOffset : COLORS.primary
}
width={0.95}
/>
) : (
<SwipeButtonNew
onSwipeSuccess={sendPayment}
width={0.85}
resetAfterSuccessAnimDuration={true}
// shouldAnimateViewOnSuccess={true}
shouldResetAfterSuccess={!canSendPayment}
// shouldDisplaySuccessState={isSendingPayment}
containerStyles={{
opacity: canSendPayment ? 1 : HIDDEN_OPACITY,
}}
thumbIconStyles={{
backgroundColor:
theme && darkModeType ? backgroundOffset : backgroundColor,
borderColor:
theme && darkModeType ? backgroundOffset : backgroundColor,
}}
railStyles={{
backgroundColor:
theme && darkModeType ? backgroundOffset : backgroundColor,
borderColor:
theme && darkModeType ? backgroundOffset : backgroundColor,
}}
/>
)}
</View>
)}
</View>
{/* Emoji bar for description input */}
{!isAmountFocused && uiState === 'EDIT_AMOUNT' && (
<EmojiQuickBar
description={combinedPaymentDescription}
onEmojiSelect={handleEmoji}
/>
)}
</CustomKeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
replacementContainer: {
flexGrow: 1,
width: WINDOWWIDTH,
...CENTER,
},
sectionTitle: {
fontSize: SIZES.medium,
opacity: HIDDEN_OPACITY,
textAlign: 'center',
marginBottom: CONTENT_KEYBOARD_OFFSET,
},
balanceScrollContainer: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
},
maxAndAcceptContainer: {
width: INSET_WINDOW_WIDTH,
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
...CENTER,
},
buttonContainer: {
alignItems: 'center',
justifyContent: 'center',
},
selectTokenText: {
textDecorationLine: 'underline',
paddingVertical: 10,
textAlign: 'center',
},
selectMethodButton: {
width: INSET_WINDOW_WIDTH,
marginBottom: 20,
},
});