BlueWallet/screen/send/Confirm.tsx

437 lines
14 KiB
TypeScript
Raw Normal View History

2024-05-31 19:18:01 +02:00
import React, { useEffect, useMemo, useReducer } from 'react';
2024-05-22 18:08:49 +02:00
import { ActivityIndicator, FlatList, TouchableOpacity, StyleSheet, Switch, View } from 'react-native';
2024-06-12 18:46:44 +02:00
import { Text } from '@rneui/themed';
2024-05-22 18:08:49 +02:00
import { PayjoinClient } from 'payjoin-client';
2024-04-09 18:14:14 +02:00
import BigNumber from 'bignumber.js';
import * as bitcoin from 'bitcoinjs-lib';
2024-05-22 18:08:49 +02:00
import { BlueText, BlueCard } from '../../BlueComponents';
import { BitcoinUnit } from '../../models/bitcoinUnits';
import loc, { formatBalance, formatBalanceWithoutSuffix } from '../../loc';
import Notifications from '../../blue_modules/notifications';
2024-05-24 17:39:54 +02:00
import { useRoute, RouteProp } from '@react-navigation/native';
import presentAlert from '../../components/Alert';
2024-05-22 18:08:49 +02:00
import { useTheme } from '../../components/themes';
2023-11-15 09:40:22 +01:00
import Button from '../../components/Button';
2024-05-22 18:08:49 +02:00
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
import SafeArea from '../../components/SafeArea';
2024-05-22 18:08:49 +02:00
import { satoshiToBTC, satoshiToLocalCurrency } from '../../blue_modules/currency';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
2024-06-04 03:27:21 +02:00
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
2024-05-24 17:39:54 +02:00
import { TWallet, CreateTransactionTarget } from '../../class/wallets/types';
2024-05-22 18:08:49 +02:00
import PayjoinTransaction from '../../class/payjoin-transaction';
2024-05-24 17:39:54 +02:00
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SendDetailsStackParamList } from '../../navigation/SendDetailsStackParamList';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
2024-05-28 00:00:28 +02:00
import { ContactList } from '../../class/contact-list';
2024-05-31 19:18:01 +02:00
import { useStorage } from '../../hooks/context/useStorage';
2024-05-24 17:39:54 +02:00
enum ActionType {
SET_LOADING = 'SET_LOADING',
SET_PAYJOIN_ENABLED = 'SET_PAYJOIN_ENABLED',
SET_BUTTON_DISABLED = 'SET_BUTTON_DISABLED',
}
type Action =
| { type: ActionType.SET_LOADING; payload: boolean }
| { type: ActionType.SET_PAYJOIN_ENABLED; payload: boolean }
| { type: ActionType.SET_BUTTON_DISABLED; payload: boolean };
2024-05-22 18:08:49 +02:00
interface State {
isLoading: boolean;
isPayjoinEnabled: boolean;
isButtonDisabled: boolean;
}
const initialState: State = {
isLoading: false,
isPayjoinEnabled: false,
isButtonDisabled: false,
};
2024-05-22 18:08:49 +02:00
const reducer = (state: State, action: Action): State => {
switch (action.type) {
2024-05-24 17:39:54 +02:00
case ActionType.SET_LOADING:
2024-05-22 18:08:49 +02:00
return { ...state, isLoading: action.payload };
2024-05-24 17:39:54 +02:00
case ActionType.SET_PAYJOIN_ENABLED:
2024-05-22 18:08:49 +02:00
return { ...state, isPayjoinEnabled: action.payload };
2024-05-24 17:39:54 +02:00
case ActionType.SET_BUTTON_DISABLED:
2024-05-22 18:08:49 +02:00
return { ...state, isButtonDisabled: action.payload };
default:
return state;
}
};
2024-05-24 17:39:54 +02:00
type ConfirmRouteProp = RouteProp<SendDetailsStackParamList, 'Confirm'>;
type ConfirmNavigationProp = NativeStackNavigationProp<SendDetailsStackParamList, 'Confirm'>;
2024-05-22 18:08:49 +02:00
const Confirm: React.FC = () => {
const { wallets, fetchAndSaveWalletTransactions, counterpartyMetadata, isElectrumDisabled } = useStorage();
2024-06-04 03:27:21 +02:00
const { isBiometricUseCapableAndEnabled } = useBiometrics();
2024-05-24 17:39:54 +02:00
const navigation = useExtendedNavigation<ConfirmNavigationProp>();
const route = useRoute<ConfirmRouteProp>(); // Get the route and its params
2024-05-28 00:00:28 +02:00
const { recipients, targets, walletID, fee, memo, tx, satoshiPerByte, psbt, payjoinUrl } = route.params; // Destructure params
2024-05-24 17:39:54 +02:00
2024-05-22 18:08:49 +02:00
const [state, dispatch] = useReducer(reducer, initialState);
2024-05-24 17:39:54 +02:00
const { navigate, setOptions, goBack } = navigation;
2024-05-22 18:08:49 +02:00
const wallet = wallets.find((w: TWallet) => w.getID() === walletID) as TWallet;
2024-04-09 18:14:14 +02:00
const feeSatoshi = new BigNumber(fee).multipliedBy(100000000).toNumber();
const { colors } = useTheme();
2024-05-22 18:08:49 +02:00
useEffect(() => {
if (!wallet) {
goBack();
}
}, [wallet, goBack]);
const stylesHook = StyleSheet.create({
transactionDetailsTitle: {
color: colors.foregroundColor,
},
transactionDetailsSubtitle: {
color: colors.feeText,
},
transactionAmountFiat: {
color: colors.feeText,
},
2021-09-08 01:41:59 +02:00
txDetails: {
backgroundColor: colors.lightButton,
},
valueValue: {
color: colors.alternativeTextColor2,
},
valueUnit: {
2021-09-08 01:41:59 +02:00
color: colors.buttonTextColor,
},
root: {
backgroundColor: colors.elevated,
},
payjoinWrapper: {
backgroundColor: colors.buttonDisabledBackgroundColor,
},
});
2024-05-22 18:08:49 +02:00
const HeaderRightButton = useMemo(
() => (
<TouchableOpacity
accessibilityRole="button"
testID="TransactionDetailsButton"
style={[styles.txDetails, stylesHook.txDetails]}
onPress={async () => {
if (await isBiometricUseCapableAndEnabled()) {
if (!(await unlockWithBiometrics())) {
return;
}
}
navigate('CreateTransaction', {
fee,
recipients,
memo,
tx,
satoshiPerByte,
wallet,
feeSatoshi,
});
}}
>
<Text style={[styles.txText, stylesHook.valueUnit]}>{loc.send.create_details}</Text>
</TouchableOpacity>
),
[
stylesHook.txDetails,
stylesHook.valueUnit,
isBiometricUseCapableAndEnabled,
navigate,
fee,
recipients,
memo,
tx,
satoshiPerByte,
wallet,
feeSatoshi,
],
);
useEffect(() => {
console.log('send/confirm - useEffect');
console.log('address = ', recipients);
2024-05-22 18:08:49 +02:00
}, [recipients]);
useEffect(() => {
setOptions({
2024-05-22 18:08:49 +02:00
headerRight: () => HeaderRightButton,
});
2024-05-22 18:08:49 +02:00
}, [HeaderRightButton, colors, fee, feeSatoshi, memo, recipients, satoshiPerByte, setOptions, tx, wallet]);
2024-05-24 17:39:54 +02:00
const getPaymentScript = (): Buffer | undefined => {
if (!(recipients.length > 0) || !recipients[0].address) {
return undefined;
}
return bitcoin.address.toOutputScript(recipients[0].address, bitcoin.networks.bitcoin);
};
2020-11-24 17:10:38 +01:00
const send = async () => {
2024-05-24 17:39:54 +02:00
dispatch({ type: ActionType.SET_BUTTON_DISABLED, payload: true });
dispatch({ type: ActionType.SET_LOADING, payload: true });
try {
const txids2watch = [];
2024-05-22 18:08:49 +02:00
if (!state.isPayjoinEnabled) {
await broadcast(tx);
} else {
2024-05-22 18:08:49 +02:00
const payJoinWallet = new PayjoinTransaction(psbt, (txHex: string) => broadcast(txHex), wallet);
const paymentScript = getPaymentScript();
2024-05-24 17:39:54 +02:00
if (!paymentScript) {
throw new Error('Invalid payment script');
}
2023-12-15 16:56:41 +01:00
const payjoinClient = new PayjoinClient({
paymentScript,
2024-05-24 17:39:54 +02:00
wallet: payJoinWallet.getPayjoinPsbt(),
2024-05-22 18:08:49 +02:00
payjoinUrl: payjoinUrl as string,
2023-12-15 16:56:41 +01:00
});
await payjoinClient.run();
const payjoinPsbt = payJoinWallet.getPayjoinPsbt();
if (payjoinPsbt) {
const tx2watch = payjoinPsbt.extractTransaction();
txids2watch.push(tx2watch.getId());
2020-09-21 21:32:20 +02:00
}
}
2019-10-05 12:11:54 +02:00
const txid = bitcoin.Transaction.fromHex(tx).getId();
txids2watch.push(txid);
2024-05-22 18:08:49 +02:00
// @ts-ignore: Notifications has to be TSed
Notifications.majorTomToGroundControl([], [], txids2watch);
let amount = 0;
for (const recipient of recipients) {
2024-05-24 17:39:54 +02:00
if (recipient.value) {
amount += recipient.value;
}
}
2020-09-21 21:32:20 +02:00
2024-05-22 18:08:49 +02:00
amount = Number(formatBalanceWithoutSuffix(amount, BitcoinUnit.BTC, false));
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
navigate('Success', {
fee: Number(fee),
amount,
txid,
});
2024-05-24 17:39:54 +02:00
dispatch({ type: ActionType.SET_LOADING, payload: false });
await new Promise(resolve => setTimeout(resolve, 3000)); // sleep to make sure network propagates
fetchAndSaveWalletTransactions(walletID);
2024-05-22 18:08:49 +02:00
} catch (error: any) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
2024-05-24 17:39:54 +02:00
dispatch({ type: ActionType.SET_LOADING, payload: false });
dispatch({ type: ActionType.SET_BUTTON_DISABLED, payload: false });
presentAlert({ message: error.message });
}
};
2024-05-22 18:08:49 +02:00
const broadcast = async (transaction: string) => {
2020-09-21 21:32:20 +02:00
await BlueElectrum.ping();
await BlueElectrum.waitTillConnected();
2024-05-18 00:34:39 +02:00
if (await isBiometricUseCapableAndEnabled()) {
if (!(await unlockWithBiometrics())) {
2020-09-21 21:32:20 +02:00
return;
}
}
const result = await wallet.broadcastTx(transaction);
2020-09-21 21:32:20 +02:00
if (!result) {
throw new Error(loc.errors.broadcast);
}
return result;
};
2020-09-21 21:32:20 +02:00
2024-05-28 00:00:28 +02:00
const shortenContactName = (name: string): string => {
if (name.length < 20) return name;
return name.substr(0, 10) + '...' + name.substr(name.length - 10, 10);
};
2024-05-24 17:39:54 +02:00
const renderItem = ({ index, item }: { index: number; item: CreateTransactionTarget }) => {
2024-05-28 00:00:28 +02:00
// first, trying to find if this destination is to a PaymentCode, and if it is - get its local alias
let contact: string = '';
try {
const cl = new ContactList();
if (targets?.[index]?.address && cl.isPaymentCodeValid(targets[index].address!)) {
// this is why we need `targets` in this screen.
// in case address was a payment code, and it got turned into a regular address, we need to display the PC as well
contact = targets[index].address!;
if (counterpartyMetadata?.[contact].label) {
contact = counterpartyMetadata?.[contact].label;
}
contact = shortenContactName(contact);
}
} catch (_) {}
return (
<>
<View style={styles.valueWrap}>
<Text testID="TransactionValue" style={[styles.valueValue, stylesHook.valueValue]}>
2024-05-24 17:39:54 +02:00
{item.value && satoshiToBTC(item.value)}
</Text>
2021-09-13 19:43:26 +02:00
<Text style={[styles.valueUnit, stylesHook.valueValue]}>{' ' + loc.units[BitcoinUnit.BTC]}</Text>
</View>
2024-05-24 17:39:54 +02:00
<Text style={[styles.transactionAmountFiat, stylesHook.transactionAmountFiat]}>
{item.value && satoshiToLocalCurrency(item.value)}
</Text>
<BlueCard>
<Text style={[styles.transactionDetailsTitle, stylesHook.transactionDetailsTitle]}>{loc.send.create_to}</Text>
<Text testID="TransactionAddress" style={[styles.transactionDetailsSubtitle, stylesHook.transactionDetailsSubtitle]}>
2020-08-11 20:16:24 +02:00
{item.address}
</Text>
2024-05-28 00:00:28 +02:00
{contact ? <Text style={[styles.transactionDetailsSubtitle, stylesHook.transactionDetailsSubtitle]}>[{contact}]</Text> : null}
</BlueCard>
{recipients.length > 1 && (
<BlueText style={styles.valueOf}>{loc.formatString(loc._.of, { number: index + 1, total: recipients.length })}</BlueText>
)}
</>
);
};
const renderSeparator = () => {
return <View style={styles.separator} />;
};
return (
<SafeArea style={[styles.root, stylesHook.root]}>
<View style={styles.cardTop}>
2024-05-24 17:39:54 +02:00
<FlatList<CreateTransactionTarget>
scrollEnabled={recipients.length > 1}
extraData={recipients}
data={recipients}
2024-05-22 18:08:49 +02:00
renderItem={renderItem}
keyExtractor={(_item, index) => `${index}`}
ItemSeparatorComponent={renderSeparator}
/>
{!!payjoinUrl && (
<View style={styles.cardContainer}>
<BlueCard>
<View style={[styles.payjoinWrapper, stylesHook.payjoinWrapper]}>
<Text style={styles.payjoinText}>Payjoin</Text>
2024-05-22 18:08:49 +02:00
<Switch
testID="PayjoinSwitch"
value={state.isPayjoinEnabled}
2024-05-24 17:39:54 +02:00
onValueChange={value => dispatch({ type: ActionType.SET_PAYJOIN_ENABLED, payload: value })}
2024-05-22 18:08:49 +02:00
/>
</View>
</BlueCard>
</View>
)}
</View>
<View style={styles.cardBottom}>
<BlueCard>
<Text style={styles.cardText} testID="TransactionFee">
2024-01-28 16:11:08 +01:00
{loc.send.create_fee}: {formatBalance(feeSatoshi, BitcoinUnit.BTC)} ({satoshiToLocalCurrency(feeSatoshi)})
</Text>
2024-05-22 18:08:49 +02:00
{state.isLoading ? (
<ActivityIndicator />
) : (
2024-05-31 17:34:00 +02:00
<Button disabled={isElectrumDisabled || state.isButtonDisabled} onPress={send} title={loc.send.confirm_sendNow} />
2024-05-22 18:08:49 +02:00
)}
</BlueCard>
</View>
</SafeArea>
);
};
export default Confirm;
const styles = StyleSheet.create({
transactionDetailsTitle: {
fontWeight: '500',
fontSize: 17,
marginBottom: 2,
},
transactionDetailsSubtitle: {
fontWeight: '500',
fontSize: 15,
marginBottom: 20,
},
2020-06-09 16:08:18 +02:00
transactionAmountFiat: {
fontWeight: '500',
fontSize: 15,
2021-04-29 17:10:30 +02:00
marginVertical: 8,
2020-06-09 16:08:18 +02:00
textAlign: 'center',
},
valueWrap: {
flexDirection: 'row',
justifyContent: 'center',
},
valueValue: {
fontSize: 36,
2021-04-29 17:10:30 +02:00
fontWeight: '700',
},
valueUnit: {
fontSize: 16,
marginHorizontal: 4,
paddingBottom: 6,
fontWeight: '600',
alignSelf: 'flex-end',
},
valueOf: {
alignSelf: 'flex-end',
marginRight: 18,
marginVertical: 8,
},
separator: {
height: 0.5,
margin: 16,
},
root: {
paddingTop: 19,
2021-04-29 17:10:30 +02:00
justifyContent: 'space-between',
},
2021-04-29 17:10:30 +02:00
cardTop: {
flexGrow: 8,
marginTop: 16,
alignItems: 'center',
2021-04-29 17:10:30 +02:00
maxHeight: '70%',
},
2021-04-29 17:10:30 +02:00
cardBottom: {
flexGrow: 2,
justifyContent: 'flex-end',
alignItems: 'center',
},
cardContainer: {
2021-04-29 17:10:30 +02:00
flexGrow: 1,
width: '100%',
},
cardText: {
2021-04-29 17:10:30 +02:00
flexDirection: 'row',
color: '#37c0a1',
fontSize: 14,
2021-04-29 17:10:30 +02:00
marginVertical: 8,
marginHorizontal: 24,
paddingBottom: 6,
fontWeight: '500',
alignSelf: 'center',
},
txDetails: {
alignItems: 'center',
justifyContent: 'center',
width: 80,
borderRadius: 8,
height: 38,
},
txText: {
fontSize: 15,
fontWeight: '600',
},
2020-09-21 21:32:20 +02:00
payjoinWrapper: {
flexDirection: 'row',
2021-04-29 17:10:30 +02:00
padding: 8,
borderRadius: 6,
width: '100%',
2020-09-21 21:32:20 +02:00
alignItems: 'center',
2021-04-29 17:10:30 +02:00
justifyContent: 'space-between',
},
payjoinText: {
color: '#81868e',
2021-04-29 17:10:30 +02:00
fontSize: 15,
fontWeight: 'bold',
2020-09-21 21:32:20 +02:00
},
});