Skip to content

Commit e5973d0

Browse files
committed
feat: allow custom fee during manual transfer flow
1 parent 136a3d4 commit e5973d0

File tree

7 files changed

+238
-11
lines changed

7 files changed

+238
-11
lines changed

e2e/channels.e2e.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,16 @@ d('Transfer', () => {
251251
4,
252252
);
253253
await element(by.id('ExternalAmountContinue')).tap();
254+
255+
// change fee
256+
await element(by.id('SetCustomFee')).tap();
257+
await element(
258+
by.id('NRemove').withAncestor(by.id('FeeCustomNumberPad')),
259+
).tap();
260+
await element(by.id('FeeCustomContinue')).tap();
261+
await element(by.id('N5').withAncestor(by.id('FeeCustomNumberPad'))).tap();
262+
await element(by.id('FeeCustomContinue')).tap();
263+
254264
// Swipe to confirm (set x offset to avoid navigating back)
255265
await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5);
256266
await waitFor(element(by.id('ExternalSuccess')))
@@ -319,7 +329,7 @@ d('Transfer', () => {
319329
await waitFor(
320330
element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))),
321331
)
322-
.toHaveText('99 225')
332+
.toHaveText('99 059')
323333
.withTimeout(10000);
324334

325335
// close the channel

src/navigation/transfer/TransferNavigator.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import ExternalConnection from '../../screens/Transfer/ExternalNode/Connection';
2929
import ExternalAmount from '../../screens/Transfer/ExternalNode/Amount';
3030
import ExternalConfirm from '../../screens/Transfer/ExternalNode/Confirm';
3131
import ExternalSuccess from '../../screens/Transfer/ExternalNode/Success';
32+
import ExternalFeeCustom from '../../screens/Transfer/ExternalNode/FeeCustom';
3233
import { TChannel } from '../../store/types/lightning';
3334

3435
export type TransferNavigationProp =
@@ -59,6 +60,7 @@ export type TransferStackParamList = {
5960
SavingsConfirm: { channels: TChannel[] } | undefined;
6061
SavingsAdvanced: undefined;
6162
SavingsProgress: { channels: TChannel[] };
63+
ExternalFeeCustom: undefined;
6264
};
6365

6466
const Stack = createNativeStackNavigator<TransferStackParamList>();
@@ -93,6 +95,7 @@ const Transfer = (): ReactElement => {
9395
<Stack.Screen name="ExternalAmount" component={ExternalAmount} />
9496
<Stack.Screen name="ExternalConfirm" component={ExternalConfirm} />
9597
<Stack.Screen name="ExternalSuccess" component={ExternalSuccess} />
98+
<Stack.Screen name="ExternalFeeCustom" component={ExternalFeeCustom} />
9699
</Stack.Navigator>
97100
);
98101
};

src/screens/Transfer/ExternalNode/Amount.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const ExternalAmount = ({
6363
useCallback(() => {
6464
const setupTransaction = async (): Promise<void> => {
6565
await resetSendTransaction();
66-
await setupOnChainTransaction({ satsPerByte: fees.fast });
66+
await setupOnChainTransaction({ satsPerByte: fees.fast, rbf: false });
6767
};
6868
setupTransaction();
6969

@@ -151,7 +151,7 @@ const ExternalAmount = ({
151151
/>
152152
</View>
153153

154-
<View style={styles.numberPad} testID="SendAmountNumberPad">
154+
<View style={styles.numberPadContainer} testID="SendAmountNumberPad">
155155
<View style={styles.actions}>
156156
<View>
157157
<Caption13Up style={styles.availableAmountText} color="secondary">
@@ -235,7 +235,7 @@ const styles = StyleSheet.create({
235235
amountContainer: {
236236
marginTop: 'auto',
237237
},
238-
numberPad: {
238+
numberPadContainer: {
239239
flex: 1,
240240
marginTop: 'auto',
241241
maxHeight: 435,

src/screens/Transfer/ExternalNode/Confirm.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import React, { ReactElement, useState } from 'react';
1+
import React, { ReactElement, useCallback, useState } from 'react';
2+
import { useFocusEffect } from '@react-navigation/native';
23
import { Image, StyleSheet, View } from 'react-native';
34
import { Trans, useTranslation } from 'react-i18next';
45

5-
import { View as ThemedView } from '../../../styles/components';
6+
import {
7+
View as ThemedView,
8+
TouchableOpacity,
9+
} from '../../../styles/components';
610
import { Caption13Up, Display } from '../../../styles/text';
7-
import { LightningIcon } from '../../../styles/icons';
11+
import { LightningIcon, PencilIcon } from '../../../styles/icons';
812
import SafeAreaInset from '../../../components/SafeAreaInset';
913
import NavigationHeader from '../../../components/NavigationHeader';
1014
import SwipeToConfirm from '../../../components/SwipeToConfirm';
@@ -14,6 +18,7 @@ import { createFundedChannel } from '../../../utils/wallet/transfer';
1418
import { useAppSelector } from '../../../hooks/redux';
1519
import { TransferScreenProps } from '../../../navigation/types';
1620
import { transactionFeeSelector } from '../../../store/reselect/wallet';
21+
import { updateSendTransaction } from '../../../store/actions/wallet';
1722

1823
const image = require('../../../assets/illustrations/coin-stack-x.png');
1924

@@ -29,6 +34,16 @@ const ExternalConfirm = ({
2934
const lspFee = 0;
3035
const totalFee = localBalance + transactionFee;
3136

37+
useFocusEffect(
38+
useCallback(() => {
39+
// Using a placeholder address here to enable the FeeCustom screen to function properly.
40+
// The actual funding address will be updated later in createFundedChannel.
41+
updateSendTransaction({
42+
outputs: [{ address: 'xxx', value: localBalance, index: 0 }],
43+
});
44+
}, [localBalance]),
45+
);
46+
3247
const onConfirm = async (): Promise<void> => {
3348
setLoading(true);
3449

@@ -50,6 +65,10 @@ const ExternalConfirm = ({
5065
navigation.navigate('ExternalSuccess');
5166
};
5267

68+
const onChangeFee = (): void => {
69+
navigation.navigate('ExternalFeeCustom');
70+
};
71+
5372
return (
5473
<ThemedView style={styles.root}>
5574
<SafeAreaInset type="top" />
@@ -68,12 +87,18 @@ const ExternalConfirm = ({
6887

6988
<View style={styles.fees}>
7089
<View style={styles.feesRow}>
71-
<View style={styles.feeItem}>
90+
<TouchableOpacity
91+
style={styles.feeItem}
92+
onPress={onChangeFee}
93+
testID="SetCustomFee">
7294
<Caption13Up style={styles.feeItemLabel} color="secondary">
7395
{t('spending_confirm.network_fee')}
7496
</Caption13Up>
75-
<Money sats={transactionFee} size="bodySSB" symbol={true} />
76-
</View>
97+
<View style={styles.networkFee}>
98+
<Money sats={transactionFee} size="bodySSB" symbol={true} />
99+
<PencilIcon height={13} width={13} />
100+
</View>
101+
</TouchableOpacity>
77102
<View style={styles.feeItem}>
78103
<Caption13Up style={styles.feeItemLabel} color="secondary">
79104
{t('spending_confirm.lsp_fee')}
@@ -143,6 +168,11 @@ const styles = StyleSheet.create({
143168
feeItemLabel: {
144169
marginBottom: 8,
145170
},
171+
networkFee: {
172+
flexDirection: 'row',
173+
alignItems: 'center',
174+
gap: 8,
175+
},
146176
imageContainer: {
147177
flexShrink: 1,
148178
alignItems: 'center',
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import React, { ReactElement, memo, useEffect, useMemo, useState } from 'react';
2+
import { Trans, useTranslation } from 'react-i18next';
3+
import { StyleSheet, View } from 'react-native';
4+
import { FadeIn, FadeOut } from 'react-native-reanimated';
5+
6+
import Amount from '../../../components/Amount';
7+
import NavigationHeader from '../../../components/NavigationHeader';
8+
import NumberPad from '../../../components/NumberPad';
9+
import SafeAreaInset from '../../../components/SafeAreaInset';
10+
import Button from '../../../components/buttons/Button';
11+
import { useDisplayValues } from '../../../hooks/displayValues';
12+
import { useAppSelector } from '../../../hooks/redux';
13+
import { TransferScreenProps } from '../../../navigation/types';
14+
import { onChainFeesSelector } from '../../../store/reselect/fees';
15+
import { transactionSelector } from '../../../store/reselect/wallet';
16+
import { AnimatedView, View as ThemedView } from '../../../styles/components';
17+
import { BodyM, Caption13Up, Display } from '../../../styles/text';
18+
import { showToast } from '../../../utils/notifications';
19+
import { handleNumberPadPress } from '../../../utils/numberpad';
20+
import { getFeeInfo } from '../../../utils/wallet';
21+
import { getTotalFee, updateFee } from '../../../utils/wallet/transactions';
22+
23+
const FeeCustom = ({
24+
navigation,
25+
}: TransferScreenProps<'ExternalFeeCustom'>): ReactElement => {
26+
const { t } = useTranslation('lightning');
27+
const feeEstimates = useAppSelector(onChainFeesSelector);
28+
const transaction = useAppSelector(transactionSelector);
29+
const [feeRate, setFeeRate] = useState<number>(transaction.satsPerByte);
30+
const [maxFee, setMaxFee] = useState(0);
31+
const minFee = feeEstimates.minimum;
32+
33+
useEffect(() => {
34+
const feeInfo = getFeeInfo({
35+
satsPerByte: transaction.satsPerByte,
36+
transaction,
37+
});
38+
if (feeInfo.isOk()) {
39+
setMaxFee(feeInfo.value.maxSatPerByte);
40+
}
41+
}, [transaction]);
42+
43+
const totalFee = getTotalFee({
44+
satsPerByte: feeRate,
45+
message: transaction.message,
46+
});
47+
48+
const totalFeeDisplay = useDisplayValues(totalFee);
49+
const totalFeeText = useMemo(() => {
50+
if (totalFeeDisplay.fiatFormatted === '—') {
51+
return t('wallet:send_fee_total', { totalFee });
52+
}
53+
return t('wallet:send_fee_total_fiat', {
54+
feeSats: totalFee,
55+
fiatSymbol: totalFeeDisplay.fiatSymbol,
56+
fiatFormatted: totalFeeDisplay.fiatFormatted,
57+
});
58+
}, [totalFee, totalFeeDisplay.fiatFormatted, totalFeeDisplay.fiatSymbol, t]);
59+
60+
const onPress = (key: string): void => {
61+
const current = feeRate.toString();
62+
const newAmount = handleNumberPadPress(key, current, { maxLength: 3 });
63+
setFeeRate(Number(newAmount));
64+
};
65+
66+
const onContinue = (): void => {
67+
if (Number(feeRate) > maxFee) {
68+
showToast({
69+
type: 'info',
70+
title: t('wallet:max_possible_fee_rate'),
71+
description: t('wallet:max_possible_fee_rate_msg'),
72+
});
73+
return;
74+
}
75+
if (Number(feeRate) < minFee) {
76+
showToast({
77+
type: 'info',
78+
title: t('wallet:min_possible_fee_rate'),
79+
description: t('wallet:min_possible_fee_rate_msg'),
80+
});
81+
return;
82+
}
83+
const res = updateFee({
84+
satsPerByte: Number(feeRate),
85+
transaction,
86+
});
87+
if (res.isErr()) {
88+
showToast({
89+
type: 'warning',
90+
title: t('wallet:send_fee_error'),
91+
description: res.error.message,
92+
});
93+
}
94+
if (res.isOk()) {
95+
navigation.goBack();
96+
}
97+
};
98+
99+
const isValid = feeRate !== 0;
100+
101+
return (
102+
<ThemedView style={styles.root}>
103+
<SafeAreaInset type="top" />
104+
<NavigationHeader
105+
title={t('external.nav_title')}
106+
onClosePress={(): void => navigation.navigate('Wallet')}
107+
/>
108+
<View style={styles.content}>
109+
<Display>
110+
<Trans
111+
t={t}
112+
i18nKey="transfer.custom_fee"
113+
components={{ accent: <Display color="purple" /> }}
114+
/>
115+
</Display>
116+
117+
<View style={styles.amountContainer}>
118+
<Caption13Up color="secondary" style={styles.title}>
119+
{t('sat_vbyte')}
120+
</Caption13Up>
121+
<Amount value={feeRate} />
122+
<View style={styles.feeText}>
123+
{isValid && (
124+
<AnimatedView entering={FadeIn} exiting={FadeOut}>
125+
<BodyM color="secondary">{totalFeeText}</BodyM>
126+
</AnimatedView>
127+
)}
128+
</View>
129+
</View>
130+
131+
<View style={styles.numberPadContainer} testID="FeeCustomNumberPad">
132+
<NumberPad style={styles.numberPad} type="simple" onPress={onPress} />
133+
</View>
134+
135+
<View style={styles.buttonContainer}>
136+
<Button
137+
size="large"
138+
text={t('continue')}
139+
onPress={onContinue}
140+
testID="FeeCustomContinue"
141+
/>
142+
</View>
143+
</View>
144+
<SafeAreaInset type="bottom" minPadding={16} />
145+
</ThemedView>
146+
);
147+
};
148+
149+
const styles = StyleSheet.create({
150+
root: {
151+
flex: 1,
152+
},
153+
content: {
154+
flex: 1,
155+
paddingTop: 16,
156+
paddingHorizontal: 16,
157+
},
158+
amountContainer: {
159+
marginTop: 'auto',
160+
},
161+
title: {
162+
marginBottom: 16,
163+
},
164+
feeText: {
165+
marginTop: 8,
166+
minHeight: 22, // avoid jumping when fee is changing
167+
},
168+
numberPadContainer: {
169+
flex: 1,
170+
marginTop: 'auto',
171+
maxHeight: 360,
172+
},
173+
numberPad: {
174+
marginTop: 16,
175+
marginHorizontal: -16,
176+
},
177+
buttonContainer: {
178+
justifyContent: 'flex-end',
179+
},
180+
});
181+
182+
export default memo(FeeCustom);

src/utils/i18n/locales/en/lightning.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
"confirm": {
7272
"string": "Please\n<accent>confirm</accent>"
7373
},
74+
"custom_fee": {
75+
"string": "Custom <accent>fee</accent>"
76+
},
7477
"swipe": {
7578
"string": "Swipe To Transfer"
7679
}

src/utils/wallet/transfer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ export const createFundedChannel = async ({
132132
const address = getAddressFromScriptPubKey(output_script, network);
133133

134134
updateSendTransaction({
135-
rbf: false,
136135
outputs: [{ address, value: value_satoshis, index: 0 }],
137136
});
138137

0 commit comments

Comments
 (0)