import React, { useEffect, useMemo, useReducer } from 'react'; import { ActivityIndicator, FlatList, TouchableOpacity, StyleSheet, Switch, View } from 'react-native'; import { Text } from '@rneui/themed'; import { PayjoinClient } from 'payjoin-client'; import BigNumber from 'bignumber.js'; import * as bitcoin from 'bitcoinjs-lib'; import { BlueText, BlueCard } from '../../BlueComponents'; import { BitcoinUnit } from '../../models/bitcoinUnits'; import loc, { formatBalance, formatBalanceWithoutSuffix } from '../../loc'; import Notifications from '../../blue_modules/notifications'; import { useRoute, RouteProp } from '@react-navigation/native'; import presentAlert from '../../components/Alert'; import { useTheme } from '../../components/themes'; import Button from '../../components/Button'; import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; import SafeArea from '../../components/SafeArea'; import { satoshiToBTC, satoshiToLocalCurrency } from '../../blue_modules/currency'; import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics'; import { TWallet, CreateTransactionTarget } from '../../class/wallets/types'; import PayjoinTransaction from '../../class/payjoin-transaction'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { SendDetailsStackParamList } from '../../navigation/SendDetailsStackParamList'; import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; import { ContactList } from '../../class/contact-list'; import { useStorage } from '../../hooks/context/useStorage'; 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 }; interface State { isLoading: boolean; isPayjoinEnabled: boolean; isButtonDisabled: boolean; } const initialState: State = { isLoading: false, isPayjoinEnabled: false, isButtonDisabled: false, }; const reducer = (state: State, action: Action): State => { switch (action.type) { case ActionType.SET_LOADING: return { ...state, isLoading: action.payload }; case ActionType.SET_PAYJOIN_ENABLED: return { ...state, isPayjoinEnabled: action.payload }; case ActionType.SET_BUTTON_DISABLED: return { ...state, isButtonDisabled: action.payload }; default: return state; } }; type ConfirmRouteProp = RouteProp; type ConfirmNavigationProp = NativeStackNavigationProp; const Confirm: React.FC = () => { const { wallets, fetchAndSaveWalletTransactions, counterpartyMetadata, isElectrumDisabled } = useStorage(); const { isBiometricUseCapableAndEnabled } = useBiometrics(); const navigation = useExtendedNavigation(); const route = useRoute(); // Get the route and its params const { recipients, targets, walletID, fee, memo, tx, satoshiPerByte, psbt, payjoinUrl } = route.params; // Destructure params const [state, dispatch] = useReducer(reducer, initialState); const { navigate, setOptions, goBack } = navigation; const wallet = wallets.find((w: TWallet) => w.getID() === walletID) as TWallet; const feeSatoshi = new BigNumber(fee).multipliedBy(100000000).toNumber(); const { colors } = useTheme(); useEffect(() => { if (!wallet) { goBack(); } }, [wallet, goBack]); const stylesHook = StyleSheet.create({ transactionDetailsTitle: { color: colors.foregroundColor, }, transactionDetailsSubtitle: { color: colors.feeText, }, transactionAmountFiat: { color: colors.feeText, }, txDetails: { backgroundColor: colors.lightButton, }, valueValue: { color: colors.alternativeTextColor2, }, valueUnit: { color: colors.buttonTextColor, }, root: { backgroundColor: colors.elevated, }, payjoinWrapper: { backgroundColor: colors.buttonDisabledBackgroundColor, }, }); const HeaderRightButton = useMemo( () => ( { if (await isBiometricUseCapableAndEnabled()) { if (!(await unlockWithBiometrics())) { return; } } navigate('CreateTransaction', { fee, recipients, memo, tx, satoshiPerByte, wallet, feeSatoshi, }); }} > {loc.send.create_details} ), [ stylesHook.txDetails, stylesHook.valueUnit, isBiometricUseCapableAndEnabled, navigate, fee, recipients, memo, tx, satoshiPerByte, wallet, feeSatoshi, ], ); useEffect(() => { console.log('send/confirm - useEffect'); console.log('address = ', recipients); }, [recipients]); useEffect(() => { setOptions({ headerRight: () => HeaderRightButton, }); }, [HeaderRightButton, colors, fee, feeSatoshi, memo, recipients, satoshiPerByte, setOptions, tx, wallet]); const getPaymentScript = (): Buffer | undefined => { if (!(recipients.length > 0) || !recipients[0].address) { return undefined; } return bitcoin.address.toOutputScript(recipients[0].address, bitcoin.networks.bitcoin); }; const send = async () => { dispatch({ type: ActionType.SET_BUTTON_DISABLED, payload: true }); dispatch({ type: ActionType.SET_LOADING, payload: true }); try { const txids2watch = []; if (!state.isPayjoinEnabled) { await broadcast(tx); } else { const payJoinWallet = new PayjoinTransaction(psbt, (txHex: string) => broadcast(txHex), wallet); const paymentScript = getPaymentScript(); if (!paymentScript) { throw new Error('Invalid payment script'); } const payjoinClient = new PayjoinClient({ paymentScript, wallet: payJoinWallet.getPayjoinPsbt(), payjoinUrl: payjoinUrl as string, }); await payjoinClient.run(); const payjoinPsbt = payJoinWallet.getPayjoinPsbt(); if (payjoinPsbt) { const tx2watch = payjoinPsbt.extractTransaction(); txids2watch.push(tx2watch.getId()); } } const txid = bitcoin.Transaction.fromHex(tx).getId(); txids2watch.push(txid); // @ts-ignore: Notifications has to be TSed Notifications.majorTomToGroundControl([], [], txids2watch); let amount = 0; for (const recipient of recipients) { if (recipient.value) { amount += recipient.value; } } amount = Number(formatBalanceWithoutSuffix(amount, BitcoinUnit.BTC, false)); triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); navigate('Success', { fee: Number(fee), amount, txid, }); dispatch({ type: ActionType.SET_LOADING, payload: false }); await new Promise(resolve => setTimeout(resolve, 3000)); // sleep to make sure network propagates fetchAndSaveWalletTransactions(walletID); } catch (error: any) { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); dispatch({ type: ActionType.SET_LOADING, payload: false }); dispatch({ type: ActionType.SET_BUTTON_DISABLED, payload: false }); presentAlert({ message: error.message }); } }; const broadcast = async (transaction: string) => { await BlueElectrum.ping(); await BlueElectrum.waitTillConnected(); if (await isBiometricUseCapableAndEnabled()) { if (!(await unlockWithBiometrics())) { return; } } const result = await wallet.broadcastTx(transaction); if (!result) { throw new Error(loc.errors.broadcast); } return result; }; const shortenContactName = (name: string): string => { if (name.length < 20) return name; return name.substr(0, 10) + '...' + name.substr(name.length - 10, 10); }; const renderItem = ({ index, item }: { index: number; item: CreateTransactionTarget }) => { // 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 ( <> {item.value && satoshiToBTC(item.value)} {' ' + loc.units[BitcoinUnit.BTC]} {item.value && satoshiToLocalCurrency(item.value)} {loc.send.create_to} {item.address} {contact ? [{contact}] : null} {recipients.length > 1 && ( {loc.formatString(loc._.of, { number: index + 1, total: recipients.length })} )} ); }; const renderSeparator = () => { return ; }; return ( scrollEnabled={recipients.length > 1} extraData={recipients} data={recipients} renderItem={renderItem} keyExtractor={(_item, index) => `${index}`} ItemSeparatorComponent={renderSeparator} /> {!!payjoinUrl && ( Payjoin dispatch({ type: ActionType.SET_PAYJOIN_ENABLED, payload: value })} /> )} {loc.send.create_fee}: {formatBalance(feeSatoshi, BitcoinUnit.BTC)} ({satoshiToLocalCurrency(feeSatoshi)}) {state.isLoading ? ( ) : (