import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { RouteProp, useRoute } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ActivityIndicator, BackHandler, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Icon } from '@rneui/themed'; import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; import { BlueCard, BlueLoading, BlueSpacing10, BlueSpacing20, BlueText } from '../../BlueComponents'; import { HDSegwitBech32Transaction, HDSegwitBech32Wallet } from '../../class'; import { Transaction, TWallet } from '../../class/wallets/types'; import Button from '../../components/Button'; import HandOffComponent from '../../components/HandOffComponent'; import TransactionIncomingIcon from '../../components/icons/TransactionIncomingIcon'; import TransactionOutgoingIcon from '../../components/icons/TransactionOutgoingIcon'; import TransactionPendingIcon from '../../components/icons/TransactionPendingIcon'; import SafeArea from '../../components/SafeArea'; import { useTheme } from '../../components/themes'; import loc, { formatBalanceWithoutSuffix } from '../../loc'; import { BitcoinUnit } from '../../models/bitcoinUnits'; import { useStorage } from '../../hooks/context/useStorage'; import { HandOffActivityType } from '../../components/types'; import HeaderRightButton from '../../components/HeaderRightButton'; import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList'; import { useSettings } from '../../hooks/context/useSettings'; import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; enum ButtonStatus { Possible, Unknown, NotPossible, } type RouteProps = RouteProp; type NavigationProps = NativeStackNavigationProp; enum ActionType { SetCPFPPossible, SetRBFBumpFeePossible, SetRBFCancelPossible, SetTransaction, SetLoading, SetEta, SetIntervalMs, SetAllButtonStatus, SetWallet, SetLoadingError, } interface State { isCPFPPossible: ButtonStatus; isRBFBumpFeePossible: ButtonStatus; isRBFCancelPossible: ButtonStatus; tx: any; isLoading: boolean; eta: string; intervalMs: number; wallet: TWallet | null; loadingError: boolean; } const initialState: State = { isCPFPPossible: ButtonStatus.Unknown, isRBFBumpFeePossible: ButtonStatus.Unknown, isRBFCancelPossible: ButtonStatus.Unknown, tx: undefined, isLoading: true, eta: '', intervalMs: 1000, wallet: null, loadingError: false, }; const reducer = (state: State, action: { type: ActionType; payload?: any }): State => { switch (action.type) { case ActionType.SetCPFPPossible: return { ...state, isCPFPPossible: action.payload }; case ActionType.SetRBFBumpFeePossible: return { ...state, isRBFBumpFeePossible: action.payload }; case ActionType.SetRBFCancelPossible: return { ...state, isRBFCancelPossible: action.payload }; case ActionType.SetTransaction: return { ...state, tx: action.payload }; case ActionType.SetLoading: return { ...state, isLoading: action.payload }; case ActionType.SetEta: return { ...state, eta: action.payload }; case ActionType.SetIntervalMs: return { ...state, intervalMs: action.payload }; case ActionType.SetAllButtonStatus: return { ...state, isCPFPPossible: action.payload, isRBFBumpFeePossible: action.payload, isRBFCancelPossible: action.payload }; case ActionType.SetWallet: return { ...state, wallet: action.payload }; case ActionType.SetLoadingError: return { ...state, loadingError: action.payload }; default: return state; } }; type TransactionStatusProps = { transaction?: { amount?: number; value?: number; confirmations?: number; }; txid?: string; }; const TransactionStatus: React.FC = ({ transaction, txid }) => { const [state, dispatch] = useReducer(reducer, initialState); const { isCPFPPossible, isRBFBumpFeePossible, isRBFCancelPossible, tx, isLoading, eta, intervalMs, wallet, loadingError } = state; const { setSelectedWalletID, wallets, txMetadata, counterpartyMetadata, fetchAndSaveWalletTransactions } = useStorage(); const { hash, walletID } = useRoute().params; const { navigate, setOptions, goBack } = useExtendedNavigation(); const { colors } = useTheme(); const { selectedBlockExplorer } = useSettings(); const fetchTxInterval = useRef(); const stylesHook = StyleSheet.create({ value: { color: colors.alternativeTextColor2, }, valueUnit: { color: colors.alternativeTextColor2, }, iconRoot: { backgroundColor: colors.success, }, }); const setTX = (value: any) => { dispatch({ type: ActionType.SetTransaction, payload: value }); }; const setIntervalMs = (ms: number) => { dispatch({ type: ActionType.SetIntervalMs, payload: ms }); }; const setEta = (value: string) => { dispatch({ type: ActionType.SetEta, payload: value }); }; const setAllButtonStatus = (status: ButtonStatus) => { dispatch({ type: ActionType.SetAllButtonStatus, payload: status }); }; const setIsLoading = (value: boolean) => { dispatch({ type: ActionType.SetLoading, payload: value }); }; const setIsCPFPPossible = (status: ButtonStatus) => { dispatch({ type: ActionType.SetCPFPPossible, payload: status }); }; const setIsRBFBumpFeePossible = (status: ButtonStatus) => { dispatch({ type: ActionType.SetRBFBumpFeePossible, payload: status }); }; const setIsRBFCancelPossible = (status: ButtonStatus) => { dispatch({ type: ActionType.SetRBFCancelPossible, payload: status }); }; const navigateToTransactionDetails = useCallback(() => { if (walletID && tx && tx.hash) { navigate('TransactionDetails', { tx, hash, walletID }); } else { console.error('Cannot navigate to TransactionDetails: Missing tx or hash.'); } }, [hash, navigate, tx, walletID]); const DetailsButton = useMemo( () => ( ), [navigateToTransactionDetails, loadingError, isLoading, tx, wallet], ); useEffect(() => { setOptions({ headerRight: () => DetailsButton, }); }, [DetailsButton, colors, hash, setOptions]); useEffect(() => { if (wallet) { const transactions = wallet.getTransactions(); const newTx = transactions.find((t: Transaction) => t.hash === hash); if (newTx) { setTX(newTx); } } }, [hash, wallet]); useEffect(() => { const foundWallet = wallets.find(w => w.getID() === walletID) || null; dispatch({ type: ActionType.SetWallet, payload: foundWallet }); }, [walletID, wallets]); // re-fetching tx status periodically useEffect(() => { console.debug('transactionStatus - useEffect'); if (!tx || tx?.confirmations) return; if (!hash) return; if (fetchTxInterval.current) { // interval already exists, lets cleanup it and recreate, so theres no duplicate intervals clearInterval(fetchTxInterval.current); fetchTxInterval.current = undefined; } console.debug('setting up interval to check tx...'); fetchTxInterval.current = setInterval(async () => { try { setIntervalMs(31000); // upon first execution we increase poll interval; console.debug('checking tx', hash, 'for confirmations...'); const transactions = await BlueElectrum.multiGetTransactionByTxid([hash], true, 10); const txFromElectrum = transactions[hash]; if (!txFromElectrum) { console.error(`Transaction from Electrum with hash ${hash} not found.`); return; } console.debug('got txFromElectrum=', txFromElectrum); const address = txFromElectrum.vout?.[0]?.scriptPubKey?.addresses?.pop(); if (!address) { console.error('Address not found in txFromElectrum.'); return; } if (!txFromElectrum.confirmations && txFromElectrum.vsize) { const txsM = await BlueElectrum.getMempoolTransactionsByAddress(address); let txFromMempool; // searching for a correct tx in case this address has several pending txs: for (const tempTxM of txsM) { if (tempTxM?.tx_hash === hash) { txFromMempool = tempTxM; break; } } if (!txFromMempool) { console.error(`Transaction from mempool with hash ${hash} not found.`); return; } console.debug('txFromMempool=', txFromMempool); const satPerVbyte = txFromMempool.fee && txFromElectrum.vsize ? Math.round(txFromMempool.fee / txFromElectrum.vsize) : 0; const fees = await BlueElectrum.estimateFees(); console.debug('fees=', fees, 'satPerVbyte=', satPerVbyte); if (satPerVbyte >= fees.fast) { setEta(loc.formatString(loc.transactions.eta_10m)); } if (satPerVbyte >= fees.medium && satPerVbyte < fees.fast) { setEta(loc.formatString(loc.transactions.eta_3h)); } if (satPerVbyte < fees.medium) { setEta(loc.formatString(loc.transactions.eta_1d)); } } else if (txFromElectrum.confirmations && txFromElectrum.confirmations > 0) { // now, handling a case when tx became confirmed! triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); setEta(''); if (tx) { setTX((prevState: any) => { return Object.assign({}, prevState, { confirmations: txFromElectrum.confirmations }); }); } else { console.error('Cannot set confirmations: tx is undefined.'); } clearInterval(fetchTxInterval.current); fetchTxInterval.current = undefined; if (wallet?.getID()) { fetchAndSaveWalletTransactions(wallet.getID()); } else { console.error('Cannot fetch and save wallet transactions: wallet ID is undefined.'); } } } catch (error) { console.error('Error in fetchTxInterval:', error); } }, intervalMs); return () => { clearInterval(fetchTxInterval.current); fetchTxInterval.current = undefined; }; }, [hash, intervalMs, tx, fetchAndSaveWalletTransactions, wallet]); const handleBackButton = () => { goBack(); return true; }; useEffect(() => { BackHandler.addEventListener('hardwareBackPress', handleBackButton); return () => { BackHandler.removeEventListener('hardwareBackPress', handleBackButton); clearInterval(fetchTxInterval.current); fetchTxInterval.current = undefined; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const initialButtonsState = async () => { try { await checkPossibilityOfCPFP(); await checkPossibilityOfRBFBumpFee(); await checkPossibilityOfRBFCancel(); } catch (e) { console.error('Error in initialButtonsState:', e); setAllButtonStatus(ButtonStatus.NotPossible); } setIsLoading(false); }; useEffect(() => { initialButtonsState().catch(error => console.error('Unhandled error in initialButtonsState:', error)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [tx, wallets]); useEffect(() => {}, [tx, wallets]); useEffect(() => { const wID = wallet?.getID(); if (wID) { setSelectedWalletID(wallet?.getID()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [wallet]); useEffect(() => { console.debug('transactionStatus - useEffect'); }, []); const checkPossibilityOfCPFP = async () => { if (!wallet?.allowRBF()) { return setIsCPFPPossible(ButtonStatus.NotPossible); } if (wallet) { const cpfbTx = new HDSegwitBech32Transaction(null, tx.hash, wallet as HDSegwitBech32Wallet); if ((await cpfbTx.isToUsTransaction()) && (await cpfbTx.getRemoteConfirmationsNum()) === 0) { return setIsCPFPPossible(ButtonStatus.Possible); } else { return setIsCPFPPossible(ButtonStatus.NotPossible); } } return setIsCPFPPossible(ButtonStatus.NotPossible); }; const checkPossibilityOfRBFBumpFee = async () => { if (!wallet?.allowRBF()) { return setIsRBFBumpFeePossible(ButtonStatus.NotPossible); } const rbfTx = new HDSegwitBech32Transaction(null, tx.hash, wallet as HDSegwitBech32Wallet); if ( (await rbfTx.isOurTransaction()) && (await rbfTx.getRemoteConfirmationsNum()) === 0 && (await rbfTx.isSequenceReplaceable()) && (await rbfTx.canBumpTx()) ) { return setIsRBFBumpFeePossible(ButtonStatus.Possible); } else { return setIsRBFBumpFeePossible(ButtonStatus.NotPossible); } }; const checkPossibilityOfRBFCancel = async () => { if (!wallet?.allowRBF()) { return setIsRBFCancelPossible(ButtonStatus.NotPossible); } const rbfTx = new HDSegwitBech32Transaction(null, tx.hash, wallet as HDSegwitBech32Wallet); if ( (await rbfTx.isOurTransaction()) && (await rbfTx.getRemoteConfirmationsNum()) === 0 && (await rbfTx.isSequenceReplaceable()) && (await rbfTx.canCancelTx()) ) { return setIsRBFCancelPossible(ButtonStatus.Possible); } else { return setIsRBFCancelPossible(ButtonStatus.NotPossible); } }; const navigateToRBFBumpFee = () => { navigate('RBFBumpFee', { txid: tx.hash, wallet, }); }; const navigateToRBFCancel = () => { navigate('RBFCancel', { txid: tx.hash, wallet, }); }; const navigateToCPFP = () => { navigate('CPFP', { txid: tx.hash, wallet, }); }; const renderCPFP = () => { if (isCPFPPossible === ButtonStatus.Unknown) { return ( <> ); } else if (isCPFPPossible === ButtonStatus.Possible) { return ( <>