diff --git a/e2e/channels.e2e.js b/e2e/channels.e2e.js index b4bf4a8a5..4ea00578d 100644 --- a/e2e/channels.e2e.js +++ b/e2e/channels.e2e.js @@ -251,6 +251,16 @@ d('Transfer', () => { 4, ); await element(by.id('ExternalAmountContinue')).tap(); + + // change fee + await element(by.id('SetCustomFee')).tap(); + await element( + by.id('NRemove').withAncestor(by.id('FeeCustomNumberPad')), + ).tap(); + await element(by.id('FeeCustomContinue')).tap(); + await element(by.id('N5').withAncestor(by.id('FeeCustomNumberPad'))).tap(); + await element(by.id('FeeCustomContinue')).tap(); + // Swipe to confirm (set x offset to avoid navigating back) await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); await waitFor(element(by.id('ExternalSuccess'))) @@ -319,7 +329,7 @@ d('Transfer', () => { await waitFor( element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), ) - .toHaveText('99 225') + .toHaveText('99 059') .withTimeout(10000); // close the channel diff --git a/src/navigation/transfer/TransferNavigator.tsx b/src/navigation/transfer/TransferNavigator.tsx index 04e9489a5..862aa5d60 100644 --- a/src/navigation/transfer/TransferNavigator.tsx +++ b/src/navigation/transfer/TransferNavigator.tsx @@ -29,6 +29,7 @@ import ExternalConnection from '../../screens/Transfer/ExternalNode/Connection'; import ExternalAmount from '../../screens/Transfer/ExternalNode/Amount'; import ExternalConfirm from '../../screens/Transfer/ExternalNode/Confirm'; import ExternalSuccess from '../../screens/Transfer/ExternalNode/Success'; +import ExternalFeeCustom from '../../screens/Transfer/ExternalNode/FeeCustom'; import { TChannel } from '../../store/types/lightning'; export type TransferNavigationProp = @@ -59,6 +60,7 @@ export type TransferStackParamList = { SavingsConfirm: { channels: TChannel[] } | undefined; SavingsAdvanced: undefined; SavingsProgress: { channels: TChannel[] }; + ExternalFeeCustom: undefined; }; const Stack = createNativeStackNavigator(); @@ -93,6 +95,7 @@ const Transfer = (): ReactElement => { + ); }; diff --git a/src/screens/Transfer/ExternalNode/Amount.tsx b/src/screens/Transfer/ExternalNode/Amount.tsx index 95c95ff9e..7e7949bd7 100644 --- a/src/screens/Transfer/ExternalNode/Amount.tsx +++ b/src/screens/Transfer/ExternalNode/Amount.tsx @@ -63,7 +63,7 @@ const ExternalAmount = ({ useCallback(() => { const setupTransaction = async (): Promise => { await resetSendTransaction(); - await setupOnChainTransaction({ satsPerByte: fees.fast }); + await setupOnChainTransaction({ satsPerByte: fees.fast, rbf: false }); }; setupTransaction(); @@ -151,7 +151,7 @@ const ExternalAmount = ({ /> - + @@ -235,7 +235,7 @@ const styles = StyleSheet.create({ amountContainer: { marginTop: 'auto', }, - numberPad: { + numberPadContainer: { flex: 1, marginTop: 'auto', maxHeight: 435, diff --git a/src/screens/Transfer/ExternalNode/Confirm.tsx b/src/screens/Transfer/ExternalNode/Confirm.tsx index f3076f944..0b3d39563 100644 --- a/src/screens/Transfer/ExternalNode/Confirm.tsx +++ b/src/screens/Transfer/ExternalNode/Confirm.tsx @@ -1,10 +1,14 @@ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, useCallback, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; import { Image, StyleSheet, View } from 'react-native'; import { Trans, useTranslation } from 'react-i18next'; -import { View as ThemedView } from '../../../styles/components'; +import { + View as ThemedView, + TouchableOpacity, +} from '../../../styles/components'; import { Caption13Up, Display } from '../../../styles/text'; -import { LightningIcon } from '../../../styles/icons'; +import { LightningIcon, PencilIcon } from '../../../styles/icons'; import SafeAreaInset from '../../../components/SafeAreaInset'; import NavigationHeader from '../../../components/NavigationHeader'; import SwipeToConfirm from '../../../components/SwipeToConfirm'; @@ -14,6 +18,7 @@ import { createFundedChannel } from '../../../utils/wallet/transfer'; import { useAppSelector } from '../../../hooks/redux'; import { TransferScreenProps } from '../../../navigation/types'; import { transactionFeeSelector } from '../../../store/reselect/wallet'; +import { updateSendTransaction } from '../../../store/actions/wallet'; const image = require('../../../assets/illustrations/coin-stack-x.png'); @@ -29,6 +34,16 @@ const ExternalConfirm = ({ const lspFee = 0; const totalFee = localBalance + transactionFee; + useFocusEffect( + useCallback(() => { + // Using a placeholder address here to enable the FeeCustom screen to function properly. + // The actual funding address will be updated later in createFundedChannel. + updateSendTransaction({ + outputs: [{ address: 'xxx', value: localBalance, index: 0 }], + }); + }, [localBalance]), + ); + const onConfirm = async (): Promise => { setLoading(true); @@ -50,6 +65,10 @@ const ExternalConfirm = ({ navigation.navigate('ExternalSuccess'); }; + const onChangeFee = (): void => { + navigation.navigate('ExternalFeeCustom'); + }; + return ( @@ -68,12 +87,18 @@ const ExternalConfirm = ({ - + {t('spending_confirm.network_fee')} - - + + + + + {t('spending_confirm.lsp_fee')} @@ -143,6 +168,11 @@ const styles = StyleSheet.create({ feeItemLabel: { marginBottom: 8, }, + networkFee: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, imageContainer: { flexShrink: 1, alignItems: 'center', diff --git a/src/screens/Transfer/ExternalNode/FeeCustom.tsx b/src/screens/Transfer/ExternalNode/FeeCustom.tsx new file mode 100644 index 000000000..950bd1bd6 --- /dev/null +++ b/src/screens/Transfer/ExternalNode/FeeCustom.tsx @@ -0,0 +1,182 @@ +import React, { ReactElement, memo, useEffect, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; +import { FadeIn, FadeOut } from 'react-native-reanimated'; + +import Amount from '../../../components/Amount'; +import NavigationHeader from '../../../components/NavigationHeader'; +import NumberPad from '../../../components/NumberPad'; +import SafeAreaInset from '../../../components/SafeAreaInset'; +import Button from '../../../components/buttons/Button'; +import { useDisplayValues } from '../../../hooks/displayValues'; +import { useAppSelector } from '../../../hooks/redux'; +import { TransferScreenProps } from '../../../navigation/types'; +import { onChainFeesSelector } from '../../../store/reselect/fees'; +import { transactionSelector } from '../../../store/reselect/wallet'; +import { AnimatedView, View as ThemedView } from '../../../styles/components'; +import { BodyM, Caption13Up, Display } from '../../../styles/text'; +import { showToast } from '../../../utils/notifications'; +import { handleNumberPadPress } from '../../../utils/numberpad'; +import { getFeeInfo } from '../../../utils/wallet'; +import { getTotalFee, updateFee } from '../../../utils/wallet/transactions'; + +const FeeCustom = ({ + navigation, +}: TransferScreenProps<'ExternalFeeCustom'>): ReactElement => { + const { t } = useTranslation('lightning'); + const feeEstimates = useAppSelector(onChainFeesSelector); + const transaction = useAppSelector(transactionSelector); + const [feeRate, setFeeRate] = useState(transaction.satsPerByte); + const [maxFee, setMaxFee] = useState(0); + const minFee = feeEstimates.minimum; + + useEffect(() => { + const feeInfo = getFeeInfo({ + satsPerByte: transaction.satsPerByte, + transaction, + }); + if (feeInfo.isOk()) { + setMaxFee(feeInfo.value.maxSatPerByte); + } + }, [transaction]); + + const totalFee = getTotalFee({ + satsPerByte: feeRate, + message: transaction.message, + }); + + const totalFeeDisplay = useDisplayValues(totalFee); + const totalFeeText = useMemo(() => { + if (totalFeeDisplay.fiatFormatted === '—') { + return t('wallet:send_fee_total', { totalFee }); + } + return t('wallet:send_fee_total_fiat', { + feeSats: totalFee, + fiatSymbol: totalFeeDisplay.fiatSymbol, + fiatFormatted: totalFeeDisplay.fiatFormatted, + }); + }, [totalFee, totalFeeDisplay.fiatFormatted, totalFeeDisplay.fiatSymbol, t]); + + const onPress = (key: string): void => { + const current = feeRate.toString(); + const newAmount = handleNumberPadPress(key, current, { maxLength: 3 }); + setFeeRate(Number(newAmount)); + }; + + const onContinue = (): void => { + if (Number(feeRate) > maxFee) { + showToast({ + type: 'info', + title: t('wallet:max_possible_fee_rate'), + description: t('wallet:max_possible_fee_rate_msg'), + }); + return; + } + if (Number(feeRate) < minFee) { + showToast({ + type: 'info', + title: t('wallet:min_possible_fee_rate'), + description: t('wallet:min_possible_fee_rate_msg'), + }); + return; + } + const res = updateFee({ + satsPerByte: Number(feeRate), + transaction, + }); + if (res.isErr()) { + showToast({ + type: 'warning', + title: t('wallet:send_fee_error'), + description: res.error.message, + }); + } + if (res.isOk()) { + navigation.goBack(); + } + }; + + const isValid = feeRate !== 0; + + return ( + + + navigation.navigate('Wallet')} + /> + + + }} + /> + + + + + {t('sat_vbyte')} + + + + {isValid && ( + + {totalFeeText} + + )} + + + + + + + + +