import React, { useState, useRef, useEffect, useCallback, useContext, useMemo, useLayoutEffect } from 'react'; import { ActivityIndicator, Alert, Dimensions, FlatList, I18nManager, Keyboard, KeyboardAvoidingView, LayoutAnimation, Platform, StyleSheet, Text, TextInput, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native'; import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native'; import { Icon } from 'react-native-elements'; import AsyncStorage from '@react-native-async-storage/async-storage'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import RNFS from 'react-native-fs'; import BigNumber from 'bignumber.js'; import * as bitcoin from 'bitcoinjs-lib'; import { BlueButton, BlueDismissKeyboardInputAccessory, BlueListItem, BlueLoading, BlueText } from '../../BlueComponents'; import { navigationStyleTx } from '../../components/navigationStyle'; import NetworkTransactionFees, { NetworkTransactionFee } from '../../models/networkTransactionFees'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; import { HDSegwitBech32Wallet, MultisigHDWallet, WatchOnlyWallet } from '../../class'; import DocumentPicker from 'react-native-document-picker'; import DeeplinkSchemaMatch from '../../class/deeplink-schema-match'; import loc, { formatBalance, formatBalanceWithoutSuffix } from '../../loc'; import CoinsSelected from '../../components/CoinsSelected'; import BottomModal from '../../components/BottomModal'; import AddressInput from '../../components/AddressInput'; import AmountInput from '../../components/AmountInput'; import InputAccessoryAllFunds from '../../components/InputAccessoryAllFunds'; import { AbstractHDElectrumWallet } from '../../class/wallets/abstract-hd-electrum-wallet'; import { BlueStorageContext } from '../../blue_modules/storage-context'; import ToolTipMenu from '../../components/TooltipMenu'; import { requestCameraAuthorization, scanQrHelper } from '../../helpers/scan-qr'; import { useTheme } from '../../components/themes'; const currency = require('../../blue_modules/currency'); const prompt = require('../../helpers/prompt'); const fs = require('../../blue_modules/fs'); const btcAddressRx = /^[a-zA-Z0-9]{26,35}$/; const SendDetails = () => { const { wallets, setSelectedWallet, sleep, txMetadata, saveToDisk } = useContext(BlueStorageContext); const navigation = useNavigation(); const { name, params: routeParams } = useRoute(); const scrollView = useRef(); const scrollIndex = useRef(0); const { colors } = useTheme(); // state const [width, setWidth] = useState(Dimensions.get('window').width); const [isLoading, setIsLoading] = useState(false); const [wallet, setWallet] = useState(null); const [walletSelectionOrCoinsSelectedHidden, setWalletSelectionOrCoinsSelectedHidden] = useState(false); const [isAmountToolbarVisibleForAndroid, setIsAmountToolbarVisibleForAndroid] = useState(false); const [isFeeSelectionModalVisible, setIsFeeSelectionModalVisible] = useState(false); const [optionsVisible, setOptionsVisible] = useState(false); const [isTransactionReplaceable, setIsTransactionReplaceable] = useState(false); const [addresses, setAddresses] = useState([]); const [units, setUnits] = useState([]); const [transactionMemo, setTransactionMemo] = useState(''); const [networkTransactionFees, setNetworkTransactionFees] = useState(new NetworkTransactionFee(3, 2, 1)); const [networkTransactionFeesIsLoading, setNetworkTransactionFeesIsLoading] = useState(false); const [customFee, setCustomFee] = useState(null); const [feePrecalc, setFeePrecalc] = useState({ current: null, slowFee: null, mediumFee: null, fastestFee: null }); const [feeUnit, setFeeUnit] = useState(); const [amountUnit, setAmountUnit] = useState(); const [utxo, setUtxo] = useState(null); const [frozenBalance, setFrozenBlance] = useState(false); const [payjoinUrl, setPayjoinUrl] = useState(null); const [changeAddress, setChangeAddress] = useState(); const [dumb, setDumb] = useState(false); const { isEditable } = routeParams; // if utxo is limited we use it to calculate available balance const balance = utxo ? utxo.reduce((prev, curr) => prev + curr.value, 0) : wallet?.getBalance(); const allBalance = formatBalanceWithoutSuffix(balance, BitcoinUnit.BTC, true); // if cutomFee is not set, we need to choose highest possible fee for wallet balance // if there are no funds for even Slow option, use 1 sat/vbyte fee const feeRate = useMemo(() => { if (customFee) return customFee; if (feePrecalc.slowFee === null) return '1'; // wait for precalculated fees let initialFee; if (feePrecalc.fastestFee !== null) { initialFee = String(networkTransactionFees.fastestFee); } else if (feePrecalc.mediumFee !== null) { initialFee = String(networkTransactionFees.mediumFee); } else { initialFee = String(networkTransactionFees.slowFee); } return initialFee; }, [customFee, feePrecalc, networkTransactionFees]); useLayoutEffect(() => { if (wallet) { setHeaderRightOptions(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [colors, wallet, isTransactionReplaceable, balance, addresses, isEditable, isLoading]); // keyboad effects useEffect(() => { const _keyboardDidShow = () => { setWalletSelectionOrCoinsSelectedHidden(true); setIsAmountToolbarVisibleForAndroid(true); }; const _keyboardDidHide = () => { setWalletSelectionOrCoinsSelectedHidden(false); setIsAmountToolbarVisibleForAndroid(false); }; Keyboard.addListener('keyboardDidShow', _keyboardDidShow); Keyboard.addListener('keyboardDidHide', _keyboardDidHide); return () => { Keyboard.removeAllListeners('keyboardDidShow'); Keyboard.removeAllListeners('keyboardDidHide'); }; }, []); useEffect(() => { // decode route params const currentAddress = addresses[scrollIndex.current]; if (routeParams.uri) { try { const { address, amount, memo, payjoinUrl: pjUrl } = DeeplinkSchemaMatch.decodeBitcoinUri(routeParams.uri); setUnits(u => { u[scrollIndex.current] = BitcoinUnit.BTC; // also resetting current unit to BTC return [...u]; }); setAddresses(addrs => { if (currentAddress) { currentAddress.address = address; if (Number(amount) > 0) { currentAddress.amount = amount; currentAddress.amountSats = currency.btcToSatoshi(amount); } addrs[scrollIndex.current] = currentAddress; return [...addrs]; } else { return [...addrs, { address, amount, amountSats: currency.btcToSatoshi(amount), key: String(Math.random()) }]; } }); if (memo?.trim().length > 0) { setTransactionMemo(memo); } setAmountUnit(BitcoinUnit.BTC); setPayjoinUrl(pjUrl); } catch (error) { console.log(error); Alert.alert(loc.errors.error, loc.send.details_error_decode); } } else if (routeParams.address) { const { amount, amountSats, unit = BitcoinUnit.BTC } = routeParams; setAddresses(addrs => { if (currentAddress) { currentAddress.address = routeParams.address; addrs[scrollIndex.current] = currentAddress; return [...addrs]; } else { return [...addrs, { address: routeParams.address, key: String(Math.random()), amount, amountSats }]; } }); if (routeParams.memo?.trim().length > 0) { setTransactionMemo(routeParams.memo); } setUnits(u => { u[scrollIndex.current] = unit; return [...u]; }); } else { setAddresses([{ address: '', key: String(Math.random()) }]); // key is for the FlatList } // eslint-disable-next-line react-hooks/exhaustive-deps }, [routeParams.uri, routeParams.address]); useEffect(() => { // check if we have a suitable wallet const suitable = wallets.filter(w => w.chain === Chain.ONCHAIN && w.allowSend()); if (suitable.length === 0) { Alert.alert(loc.errors.error, loc.send.details_wallet_before_tx); navigation.goBack(); return; } const newWallet = (routeParams.walletID && wallets.find(w => w.getID() === routeParams.walletID)) || suitable[0]; setWallet(newWallet); setFeeUnit(newWallet.getPreferredBalanceUnit()); setAmountUnit(newWallet.preferredBalanceUnit); // default for whole screen // we are ready! setIsLoading(false); // load cached fees AsyncStorage.getItem(NetworkTransactionFee.StorageKey) .then(res => { const fees = JSON.parse(res); if (!fees?.fastestFee) return; setNetworkTransactionFees(fees); }) .catch(e => console.log('loading cached recommendedFees error', e)); // load fresh fees from servers setNetworkTransactionFeesIsLoading(true); NetworkTransactionFees.recommendedFees() .then(async fees => { if (!fees?.fastestFee) return; setNetworkTransactionFees(fees); await AsyncStorage.setItem(NetworkTransactionFee.StorageKey, JSON.stringify(fees)); }) .catch(e => console.log('loading recommendedFees error', e)) .finally(() => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setNetworkTransactionFeesIsLoading(false); }); }, []); // eslint-disable-line react-hooks/exhaustive-deps // change header and reset state on wallet change useEffect(() => { if (!wallet) return; setSelectedWallet(wallet.getID()); // reset other values setUtxo(null); setChangeAddress(null); setIsTransactionReplaceable(wallet.type === HDSegwitBech32Wallet.type && !routeParams.noRbf); // update wallet UTXO wallet .fetchUtxo() .then(() => { // we need to re-calculate fees setDumb(v => !v); }) .catch(e => console.log('fetchUtxo error', e)); }, [wallet]); // eslint-disable-line react-hooks/exhaustive-deps // recalc fees in effect so we don't block render useEffect(() => { if (!wallet) return; // wait for it const fees = networkTransactionFees; const change = getChangeAddressFast(); const requestedSatPerByte = Number(feeRate); const lutxo = utxo || wallet.getUtxo(); let frozen = 0; if (!utxo) { // if utxo is not limited search for frozen outputs and calc it's balance frozen = wallet .getUtxo(true) .filter(o => !lutxo.some(i => i.txid === o.txid && i.vout === o.vout)) .reduce((prev, curr) => prev + curr.value, 0); } const options = [ { key: 'current', fee: requestedSatPerByte }, { key: 'slowFee', fee: fees.slowFee }, { key: 'mediumFee', fee: fees.mediumFee }, { key: 'fastestFee', fee: fees.fastestFee }, ]; const newFeePrecalc = { ...feePrecalc }; for (const opt of options) { let targets = []; for (const transaction of addresses) { if (transaction.amount === BitcoinUnit.MAX) { // single output with MAX targets = [{ address: transaction.address }]; break; } const value = parseInt(transaction.amountSats, 10); if (value > 0) { targets.push({ address: transaction.address, value }); } else if (transaction.amount) { if (currency.btcToSatoshi(transaction.amount) > 0) { targets.push({ address: transaction.address, value: currency.btcToSatoshi(transaction.amount) }); } } } // if targets is empty, insert dust if (targets.length === 0) { targets.push({ address: '36JxaUrpDzkEerkTf1FzwHNE1Hb7cCjgJV', value: 546 }); } // replace wrong addresses with dump targets = targets.map(t => { if (!wallet.isAddressValid(t.address)) { return { ...t, address: '36JxaUrpDzkEerkTf1FzwHNE1Hb7cCjgJV' }; } else { return t; } }); let flag = false; while (true) { try { const { fee } = wallet.coinselect(lutxo, targets, opt.fee, change); newFeePrecalc[opt.key] = fee; break; } catch (e) { if (e.message.includes('Not enough') && !flag) { flag = true; // if we don't have enough funds, construct maximum possible transaction targets = targets.map((t, index) => (index > 0 ? { ...t, value: 546 } : { address: t.address })); continue; } newFeePrecalc[opt.key] = null; break; } } } setFeePrecalc(newFeePrecalc); setFrozenBlance(frozen); }, [wallet, networkTransactionFees, utxo, addresses, feeRate, dumb]); // eslint-disable-line react-hooks/exhaustive-deps // we need to re-calculate fees if user opens-closes coin control useFocusEffect( useCallback(() => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setDumb(v => !v); }, []), ); const getChangeAddressFast = () => { if (changeAddress) return changeAddress; // cache let change; if (WatchOnlyWallet.type === wallet.type && !wallet.isHd()) { // plain watchonly - just get the address change = wallet.getAddress(); } else if (WatchOnlyWallet.type === wallet.type || wallet instanceof AbstractHDElectrumWallet) { change = wallet._getInternalAddressByIndex(wallet.getNextFreeChangeAddressIndex()); } else { // legacy wallets change = wallet.getAddress(); } return change; }; const getChangeAddressAsync = async () => { if (changeAddress) return changeAddress; // cache let change; if (WatchOnlyWallet.type === wallet.type && !wallet.isHd()) { // plain watchonly - just get the address change = wallet.getAddress(); } else { // otherwise, lets call widely-used getChangeAddressAsync() try { change = await Promise.race([sleep(2000), wallet.getChangeAddressAsync()]); } catch (_) {} if (!change) { // either sleep expired or getChangeAddressAsync threw an exception if (wallet instanceof AbstractHDElectrumWallet) { change = wallet._getInternalAddressByIndex(wallet.getNextFreeChangeAddressIndex()); } else { // legacy wallets change = wallet.getAddress(); } } } if (change) setChangeAddress(change); // cache return change; }; /** * TODO: refactor this mess, get rid of regexp, use https://github.com/bitcoinjs/bitcoinjs-lib/issues/890 etc etc * * @param data {String} Can be address or `bitcoin:xxxxxxx` uri scheme, or invalid garbage */ const processAddressData = data => { const currentIndex = scrollIndex.current; setIsLoading(true); if (!data.replace) { // user probably scanned PSBT and got an object instead of string..? setIsLoading(false); return Alert.alert(loc.errors.error, loc.send.details_address_field_is_not_valid); } const dataWithoutSchema = data.replace('bitcoin:', '').replace('BITCOIN:', ''); if (wallet.isAddressValid(dataWithoutSchema)) { setAddresses(addrs => { addrs[scrollIndex.current].address = dataWithoutSchema; return [...addrs]; }); setIsLoading(false); return; } let address = ''; let options; try { if (!data.toLowerCase().startsWith('bitcoin:')) data = `bitcoin:${data}`; const decoded = DeeplinkSchemaMatch.bip21decode(data); address = decoded.address; options = decoded.options; } catch (error) { data = data.replace(/(amount)=([^&]+)/g, '').replace(/(amount)=([^&]+)&/g, ''); const decoded = DeeplinkSchemaMatch.bip21decode(data); decoded.options.amount = 0; address = decoded.address; options = decoded.options; } console.log('options', options); if (btcAddressRx.test(address) || address.startsWith('bc1') || address.startsWith('BC1')) { setAddresses(addrs => { addrs[scrollIndex.current].address = address; addrs[scrollIndex.current].amount = options.amount; addrs[scrollIndex.current].amountSats = new BigNumber(options.amount).multipliedBy(100000000).toNumber(); return [...addrs]; }); setUnits(u => { u[scrollIndex.current] = BitcoinUnit.BTC; // also resetting current unit to BTC return [...u]; }); setTransactionMemo(options.label || options.message); setAmountUnit(BitcoinUnit.BTC); setPayjoinUrl(options.pj || ''); // RN Bug: contentOffset gets reset to 0 when state changes. Remove code once this bug is resolved. setTimeout(() => scrollView.current.scrollToIndex({ index: currentIndex, animated: false }), 50); } setIsLoading(false); }; const createTransaction = async () => { Keyboard.dismiss(); setIsLoading(true); const requestedSatPerByte = feeRate; for (const [index, transaction] of addresses.entries()) { let error; if (!transaction.amount || transaction.amount < 0 || parseFloat(transaction.amount) === 0) { error = loc.send.details_amount_field_is_not_valid; console.log('validation error'); } else if (parseFloat(transaction.amountSats) <= 500) { error = loc.send.details_amount_field_is_less_than_minimum_amount_sat; console.log('validation error'); } else if (!requestedSatPerByte || parseFloat(requestedSatPerByte) < 1) { error = loc.send.details_fee_field_is_not_valid; console.log('validation error'); } else if (!transaction.address) { error = loc.send.details_address_field_is_not_valid; console.log('validation error'); } else if (balance - transaction.amountSats < 0) { // first sanity check is that sending amount is not bigger than available balance error = frozenBalance > 0 ? loc.send.details_total_exceeds_balance_frozen : loc.send.details_total_exceeds_balance; console.log('validation error'); } else if (transaction.address) { const address = transaction.address.trim().toLowerCase(); if (address.startsWith('lnb') || address.startsWith('lightning:lnb')) { error = loc.send.provided_address_is_invoice; console.log('validation error'); } } if (!error) { if (!wallet.isAddressValid(transaction.address)) { console.log('validation error'); error = loc.send.details_address_field_is_not_valid; } } if (error) { scrollView.current.scrollToIndex({ index }); setIsLoading(false); Alert.alert(loc.errors.error, error); ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); return; } } try { await createPsbtTransaction(); } catch (Err) { setIsLoading(false); Alert.alert(loc.errors.error, Err.message); ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); } }; const createPsbtTransaction = async () => { const change = await getChangeAddressAsync(); const requestedSatPerByte = Number(feeRate); const lutxo = utxo || wallet.getUtxo(); console.log({ requestedSatPerByte, lutxo: lutxo.length }); const targets = []; for (const transaction of addresses) { if (transaction.amount === BitcoinUnit.MAX) { // output with MAX targets.push({ address: transaction.address }); continue; } const value = parseInt(transaction.amountSats, 10); if (value > 0) { targets.push({ address: transaction.address, value }); } else if (transaction.amount) { if (currency.btcToSatoshi(transaction.amount) > 0) { targets.push({ address: transaction.address, value: currency.btcToSatoshi(transaction.amount) }); } } } const { tx, outputs, psbt, fee } = wallet.createTransaction( lutxo, targets, requestedSatPerByte, change, isTransactionReplaceable ? HDSegwitBech32Wallet.defaultRBFSequence : HDSegwitBech32Wallet.finalRBFSequence, ); if (tx && routeParams.launchedBy && psbt) { console.warn('navigating back to ', routeParams.launchedBy); navigation.navigate(routeParams.launchedBy, { psbt }); } if (wallet.type === WatchOnlyWallet.type) { // watch-only wallets with enabled HW wallet support have different flow. we have to show PSBT to user as QR code // so he can scan it and sign it. then we have to scan it back from user (via camera and QR code), and ask // user whether he wants to broadcast it navigation.navigate('PsbtWithHardwareWallet', { memo: transactionMemo, fromWallet: wallet, psbt, launchedBy: routeParams.launchedBy, }); setIsLoading(false); return; } if (wallet.type === MultisigHDWallet.type) { navigation.navigate('PsbtMultisig', { memo: transactionMemo, psbtBase64: psbt.toBase64(), walletID: wallet.getID(), launchedBy: routeParams.launchedBy, }); setIsLoading(false); return; } txMetadata[tx.getId()] = { txhex: tx.toHex(), memo: transactionMemo, }; await saveToDisk(); let recipients = outputs.filter(({ address }) => address !== change); if (recipients.length === 0) { // special case. maybe the only destination in this transaction is our own change address..? // (ez can be the case for single-address wallet when doing self-payment for consolidation) recipients = outputs; } navigation.navigate('Confirm', { fee: new BigNumber(fee).dividedBy(100000000).toNumber(), memo: transactionMemo, walletID: wallet.getID(), tx: tx.toHex(), recipients, satoshiPerByte: requestedSatPerByte, payjoinUrl, psbt, }); setIsLoading(false); }; const onWalletSelect = w => { setWallet(w); navigation.pop(); }; /** * same as `importTransaction`, but opens camera instead. * * @returns {Promise} */ const importQrTransaction = () => { if (wallet.type !== WatchOnlyWallet.type) { return Alert.alert(loc.errors.error, 'Error: importing transaction in non-watchonly wallet (this should never happen)'); } setOptionsVisible(false); requestCameraAuthorization().then(() => { navigation.navigate('ScanQRCodeRoot', { screen: 'ScanQRCode', params: { onBarScanned: importQrTransactionOnBarScanned, showFileImportButton: false, }, }); }); }; const importQrTransactionOnBarScanned = ret => { navigation.getParent().pop(); if (!ret.data) ret = { data: ret }; if (ret.data.toUpperCase().startsWith('UR')) { Alert.alert(loc.errors.error, 'BC-UR not decoded. This should never happen'); } else if (ret.data.indexOf('+') === -1 && ret.data.indexOf('=') === -1 && ret.data.indexOf('=') === -1) { // this looks like NOT base64, so maybe its transaction's hex // we dont support it in this flow } else { // psbt base64? // we construct PSBT object and pass to next screen // so user can do smth with it: const psbt = bitcoin.Psbt.fromBase64(ret.data); navigation.navigate('PsbtWithHardwareWallet', { memo: transactionMemo, fromWallet: wallet, psbt, }); setIsLoading(false); setOptionsVisible(false); } }; /** * watch-only wallets with enabled HW wallet support have different flow. we have to show PSBT to user as QR code * so he can scan it and sign it. then we have to scan it back from user (via camera and QR code), and ask * user whether he wants to broadcast it. * alternatively, user can export psbt file, sign it externally and then import it * * @returns {Promise} */ const importTransaction = async () => { if (wallet.type !== WatchOnlyWallet.type) { return Alert.alert(loc.errors.error, 'Importing transaction in non-watchonly wallet (this should never happen)'); } try { const res = await DocumentPicker.pickSingle({ type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn', DocumentPicker.types.plainText, 'public.json'] : [DocumentPicker.types.allFiles], }); if (DeeplinkSchemaMatch.isPossiblySignedPSBTFile(res.uri)) { // we assume that transaction is already signed, so all we have to do is get txhex and pass it to next screen // so user can broadcast: const file = await RNFS.readFile(res.uri, 'ascii'); const psbt = bitcoin.Psbt.fromBase64(file); const txhex = psbt.extractTransaction().toHex(); navigation.navigate('PsbtWithHardwareWallet', { memo: transactionMemo, fromWallet: wallet, txhex }); setIsLoading(false); setOptionsVisible(false); return; } if (DeeplinkSchemaMatch.isPossiblyPSBTFile(res.uri)) { // looks like transaction is UNsigned, so we construct PSBT object and pass to next screen // so user can do smth with it: const file = await RNFS.readFile(res.uri, 'ascii'); const psbt = bitcoin.Psbt.fromBase64(file); navigation.navigate('PsbtWithHardwareWallet', { memo: transactionMemo, fromWallet: wallet, psbt }); setIsLoading(false); setOptionsVisible(false); return; } if (DeeplinkSchemaMatch.isTXNFile(res.uri)) { // plain text file with txhex ready to broadcast const file = (await RNFS.readFile(res.uri, 'ascii')).replace('\n', '').replace('\r', ''); navigation.navigate('PsbtWithHardwareWallet', { memo: transactionMemo, fromWallet: wallet, txhex: file }); setIsLoading(false); setOptionsVisible(false); return; } Alert.alert(loc.errors.error, loc.send.details_unrecognized_file_format); } catch (err) { if (!DocumentPicker.isCancel(err)) { Alert.alert(loc.errors.error, loc.send.details_no_signed_tx); } } }; const askCosignThisTransaction = async () => { return new Promise(resolve => { Alert.alert( '', loc.multisig.cosign_this_transaction, [ { text: loc._.no, style: 'cancel', onPress: () => resolve(false), }, { text: loc._.yes, onPress: () => resolve(true), }, ], { cancelable: false }, ); }); }; const _importTransactionMultisig = async base64arg => { try { const base64 = base64arg || (await fs.openSignedTransaction()); if (!base64) return; const psbt = bitcoin.Psbt.fromBase64(base64); // if it doesnt throw - all good, its valid if (wallet.howManySignaturesCanWeMake() > 0 && (await askCosignThisTransaction())) { hideOptions(); setIsLoading(true); await sleep(100); wallet.cosignPsbt(psbt); setIsLoading(false); await sleep(100); } navigation.navigate('PsbtMultisig', { memo: transactionMemo, psbtBase64: psbt.toBase64(), walletID: wallet.getID(), }); } catch (error) { Alert.alert(loc.send.problem_with_psbt, error.message); } setIsLoading(false); setOptionsVisible(false); }; const importTransactionMultisig = () => { return _importTransactionMultisig(); }; const onBarScanned = ret => { navigation.getParent().pop(); if (!ret.data) ret = { data: ret }; if (ret.data.toUpperCase().startsWith('UR')) { Alert.alert(loc.errors.error, 'BC-UR not decoded. This should never happen'); } else if (ret.data.indexOf('+') === -1 && ret.data.indexOf('=') === -1 && ret.data.indexOf('=') === -1) { // this looks like NOT base64, so maybe its transaction's hex // we dont support it in this flow } else { // psbt base64? return _importTransactionMultisig(ret.data); } }; const importTransactionMultisigScanQr = () => { setOptionsVisible(false); requestCameraAuthorization().then(() => { navigation.navigate('ScanQRCodeRoot', { screen: 'ScanQRCode', params: { onBarScanned, showFileImportButton: true, }, }); }); }; const handleAddRecipient = async () => { setAddresses(addrs => [...addrs, { address: '', key: String(Math.random()) }]); setOptionsVisible(false); await sleep(200); // wait for animation scrollView.current.scrollToEnd(); if (addresses.length === 0) return; scrollView.current.flashScrollIndicators(); }; const handleRemoveRecipient = async () => { const last = scrollIndex.current === addresses.length - 1; LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setAddresses(addrs => { addrs.splice(scrollIndex.current, 1); return [...addrs]; }); setOptionsVisible(false); if (addresses.length === 0) return; await sleep(200); // wait for animation scrollView.current.flashScrollIndicators(); if (last && Platform.OS === 'android') scrollView.current.scrollToEnd(); // fix white screen on android }; const handleCoinControl = () => { setOptionsVisible(false); navigation.navigate('CoinControl', { walletID: wallet.getID(), onUTXOChoose: u => setUtxo(u), }); }; const handlePsbtSign = async () => { setIsLoading(true); setOptionsVisible(false); await new Promise(resolve => setTimeout(resolve, 100)); // sleep for animations const scannedData = await scanQrHelper(navigation.navigate, name); if (!scannedData) return setIsLoading(false); let tx; let psbt; try { psbt = bitcoin.Psbt.fromBase64(scannedData); tx = wallet.cosignPsbt(psbt).tx; } catch (e) { Alert.alert(loc.errors.error, e.message); return; } finally { setIsLoading(false); } if (!tx) return setIsLoading(false); // we need to remove change address from recipients, so that Confirm screen show more accurate info const changeAddresses = []; for (let c = 0; c < wallet.next_free_change_address_index + wallet.gap_limit; c++) { changeAddresses.push(wallet._getInternalAddressByIndex(c)); } const recipients = psbt.txOutputs.filter(({ address }) => !changeAddresses.includes(address)); navigation.navigate('CreateTransaction', { fee: new BigNumber(psbt.getFee()).dividedBy(100000000).toNumber(), feeSatoshi: psbt.getFee(), wallet, tx: tx.toHex(), recipients, satoshiPerByte: psbt.getFeeRate(), showAnimatedQr: true, psbt, }); }; const hideOptions = () => { Keyboard.dismiss(); setOptionsVisible(false); }; // Header Right Button const headerRightOnPress = id => { if (id === SendDetails.actionKeys.AddRecipient) { handleAddRecipient(); } else if (id === SendDetails.actionKeys.RemoveRecipient) { handleRemoveRecipient(); } else if (id === SendDetails.actionKeys.SignPSBT) { handlePsbtSign(); } else if (id === SendDetails.actionKeys.SendMax) { onUseAllPressed(); } else if (id === SendDetails.actionKeys.AllowRBF) { onReplaceableFeeSwitchValueChanged(!isTransactionReplaceable); } else if (id === SendDetails.actionKeys.ImportTransaction) { importTransaction(); } else if (id === SendDetails.actionKeys.ImportTransactionQR) { importQrTransaction(); } else if (id === SendDetails.actionKeys.ImportTransactionMultsig) { importTransactionMultisig(); } else if (id === SendDetails.actionKeys.CoSignTransaction) { importTransactionMultisigScanQr(); } else if (id === SendDetails.actionKeys.CoinControl) { handleCoinControl(); } }; const headerRightActions = () => { const actions = []; if (isEditable) { const isSendMaxUsed = addresses.some(element => element.amount === BitcoinUnit.MAX); actions.push([{ id: SendDetails.actionKeys.SendMax, text: loc.send.details_adv_full, disabled: balance === 0 || isSendMaxUsed }]); if (wallet.type === HDSegwitBech32Wallet.type) { actions.push([{ id: SendDetails.actionKeys.AllowRBF, text: loc.send.details_adv_fee_bump, menuStateOn: isTransactionReplaceable }]); } const transactionActions = []; if (wallet.type === WatchOnlyWallet.type && wallet.isHd()) { transactionActions.push( { id: SendDetails.actionKeys.ImportTransaction, text: loc.send.details_adv_import, icon: SendDetails.actionIcons.ImportTransaction, }, { id: SendDetails.actionKeys.ImportTransactionQR, text: loc.send.details_adv_import_qr, icon: SendDetails.actionIcons.ImportTransactionQR, }, ); } if (wallet.type === MultisigHDWallet.type) { transactionActions.push({ id: SendDetails.actionKeys.ImportTransactionMultsig, text: loc.send.details_adv_import, icon: SendDetails.actionIcons.ImportTransactionMultsig, }); } if (wallet.type === MultisigHDWallet.type && wallet.howManySignaturesCanWeMake() > 0) { transactionActions.push({ id: SendDetails.actionKeys.CoSignTransaction, text: loc.multisig.co_sign_transaction, icon: SendDetails.actionIcons.SignPSBT, }); } if (wallet.allowCosignPsbt()) { transactionActions.push({ id: SendDetails.actionKeys.SignPSBT, text: loc.send.psbt_sign, icon: SendDetails.actionIcons.SignPSBT }); } actions.push(transactionActions, [ { id: SendDetails.actionKeys.AddRecipient, text: loc.send.details_add_rec_add, icon: SendDetails.actionIcons.AddRecipient, }, { id: SendDetails.actionKeys.RemoveRecipient, text: loc.send.details_add_rec_rem, disabled: addresses.length < 2, icon: SendDetails.actionIcons.RemoveRecipient, }, ]); } actions.push({ id: SendDetails.actionKeys.CoinControl, text: loc.cc.header, icon: SendDetails.actionIcons.CoinControl }); return actions; }; const setHeaderRightOptions = () => { navigation.setOptions({ headerRight: Platform.select({ // eslint-disable-next-line react/no-unstable-nested-components ios: () => ( ), // eslint-disable-next-line react/no-unstable-nested-components default: () => ( { Keyboard.dismiss(); setOptionsVisible(true); }} testID="advancedOptionsMenuButton" > ), }), }); }; const onReplaceableFeeSwitchValueChanged = value => { setIsTransactionReplaceable(value); }; // // because of https://github.com/facebook/react-native/issues/21718 we use // onScroll for android and onMomentumScrollEnd for iOS const handleRecipientsScrollEnds = e => { if (Platform.OS === 'android') return; // for android we use handleRecipientsScroll const contentOffset = e.nativeEvent.contentOffset; const viewSize = e.nativeEvent.layoutMeasurement; const index = Math.floor(contentOffset.x / viewSize.width); scrollIndex.current = index; }; const handleRecipientsScroll = e => { if (Platform.OS === 'ios') return; // for iOS we use handleRecipientsScrollEnds const contentOffset = e.nativeEvent.contentOffset; const viewSize = e.nativeEvent.layoutMeasurement; const index = Math.floor(contentOffset.x / viewSize.width); scrollIndex.current = index; }; const onUseAllPressed = () => { ReactNativeHapticFeedback.trigger('notificationWarning'); const message = frozenBalance > 0 ? loc.send.details_adv_full_sure_frozen : loc.send.details_adv_full_sure; Alert.alert( loc.send.details_adv_full, message, [ { text: loc._.ok, onPress: () => { Keyboard.dismiss(); setAddresses(addrs => { addrs[scrollIndex.current].amount = BitcoinUnit.MAX; addrs[scrollIndex.current].amountSats = BitcoinUnit.MAX; return [...addrs]; }); setUnits(u => { u[scrollIndex.current] = BitcoinUnit.BTC; return [...u]; }); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setOptionsVisible(false); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); }; const formatFee = fee => formatBalance(fee, feeUnit, true); const stylesHook = StyleSheet.create({ loading: { backgroundColor: colors.background, }, root: { backgroundColor: colors.elevated, }, modalContent: { backgroundColor: colors.modal, borderTopColor: colors.borderTopColor, borderWidth: colors.borderWidth, }, optionsContent: { backgroundColor: colors.modal, borderTopColor: colors.borderTopColor, borderWidth: colors.borderWidth, }, feeModalItemActive: { backgroundColor: colors.feeActive, }, feeModalLabel: { color: colors.successColor, }, feeModalTime: { backgroundColor: colors.successColor, }, feeModalTimeText: { color: colors.background, }, feeModalValue: { color: colors.successColor, }, feeModalCustomText: { color: colors.buttonAlternativeTextColor, }, selectLabel: { color: colors.buttonTextColor, }, of: { color: colors.feeText, }, memo: { borderColor: colors.formBorder, borderBottomColor: colors.formBorder, backgroundColor: colors.inputBackgroundColor, }, feeLabel: { color: colors.feeText, }, feeModalItemDisabled: { backgroundColor: colors.buttonDisabledBackgroundColor, }, feeModalItemTextDisabled: { color: colors.buttonDisabledTextColor, }, feeRow: { backgroundColor: colors.feeLabel, }, feeValue: { color: colors.feeValue, }, }); const renderFeeSelectionModal = () => { const nf = networkTransactionFees; const options = [ { label: loc.send.fee_fast, time: loc.send.fee_10m, fee: feePrecalc.fastestFee, rate: nf.fastestFee, active: Number(feeRate) === nf.fastestFee, }, { label: loc.send.fee_medium, time: loc.send.fee_3h, fee: feePrecalc.mediumFee, rate: nf.mediumFee, active: Number(feeRate) === nf.mediumFee, disabled: nf.mediumFee === nf.fastestFee, }, { label: loc.send.fee_slow, time: loc.send.fee_1d, fee: feePrecalc.slowFee, rate: nf.slowFee, active: Number(feeRate) === nf.slowFee, disabled: nf.slowFee === nf.mediumFee || nf.slowFee === nf.fastestFee, }, ]; return ( setIsFeeSelectionModalVisible(false)} > {options.map(({ label, time, fee, rate, active, disabled }, index) => ( { setFeePrecalc(fp => ({ ...fp, current: fee })); setIsFeeSelectionModalVisible(false); setCustomFee(rate.toString()); }} style={[styles.feeModalItem, active && styles.feeModalItemActive, active && !disabled && stylesHook.feeModalItemActive]} > {label} ~{time} {fee && formatFee(fee)} {rate} {loc.units.sat_vbyte} ))} { let error = loc.send.fee_satvbyte; while (true) { let fee; try { fee = await prompt(loc.send.create_fee, error, true, 'numeric'); } catch (_) { return; } if (!/^\d+$/.test(fee)) { error = loc.send.details_fee_field_is_not_valid; continue; } if (fee < 1) fee = '1'; fee = Number(fee).toString(); // this will remove leading zeros if any setCustomFee(fee); setIsFeeSelectionModalVisible(false); return; } }} > {loc.send.fee_custom} ); }; const renderOptionsModal = () => { const isSendMaxUsed = addresses.some(element => element.amount === BitcoinUnit.MAX); return ( {isEditable && ( )} {wallet.type === HDSegwitBech32Wallet.type && isEditable && ( )} {wallet.type === WatchOnlyWallet.type && wallet.isHd() && ( )} {wallet.type === WatchOnlyWallet.type && wallet.isHd() && ( )} {wallet.type === MultisigHDWallet.type && isEditable && ( )} {wallet.type === MultisigHDWallet.type && wallet.howManySignaturesCanWeMake() > 0 && isEditable && ( )} {isEditable && ( <> )} {wallet.allowCosignPsbt() && isEditable && ( )} ); }; const renderCreateButton = () => { return ( {isLoading ? ( ) : ( )} ); }; const renderWalletSelectionOrCoinsSelected = () => { if (walletSelectionOrCoinsSelectedHidden) return null; if (utxo !== null) { return ( { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setUtxo(null); }} /> ); } return ( {!isLoading && isEditable && ( navigation.navigate('SelectWallet', { onWalletSelect, chainType: Chain.ONCHAIN })} > {loc.wallets.select_wallet.toLowerCase()} )} navigation.navigate('SelectWallet', { onWalletSelect, chainType: Chain.ONCHAIN })} disabled={!isEditable || isLoading} > {wallet.getLabel()} ); }; const renderBitcoinTransactionInfoFields = params => { const { item, index } = params; return ( { setAddresses(addrs => { const addr = addrs[index]; switch (unit) { case BitcoinUnit.SATS: addr.amountSats = parseInt(addr.amount, 10); break; case BitcoinUnit.BTC: addr.amountSats = currency.btcToSatoshi(addr.amount); break; case BitcoinUnit.LOCAL_CURRENCY: // also accounting for cached fiat->sat conversion to avoid rounding error addr.amountSats = AmountInput.getCachedSatoshis(addr.amount) || currency.btcToSatoshi(currency.fiatToBTC(addr.amount)); break; } addrs[index] = addr; return [...addrs]; }); setUnits(u => { u[index] = unit; return [...u]; }); }} onChangeText={text => { setAddresses(addrs => { item.amount = text; switch (units[index] || amountUnit) { case BitcoinUnit.BTC: item.amountSats = currency.btcToSatoshi(item.amount); break; case BitcoinUnit.LOCAL_CURRENCY: item.amountSats = currency.btcToSatoshi(currency.fiatToBTC(item.amount)); break; case BitcoinUnit.SATS: default: item.amountSats = parseInt(text, 10); break; } addrs[index] = item; return [...addrs]; }); }} unit={units[index] || amountUnit} editable={isEditable} disabled={!isEditable} inputAccessoryViewID={InputAccessoryAllFunds.InputAccessoryViewID} /> {frozenBalance > 0 && ( {loc.formatString(loc.send.details_frozen, { amount: formatBalanceWithoutSuffix(frozenBalance, BitcoinUnit.BTC, true) })} )} { text = text.trim(); const { address, amount, memo, payjoinUrl: pjUrl } = DeeplinkSchemaMatch.decodeBitcoinUri(text); setAddresses(addrs => { item.address = address || text; item.amount = amount || item.amount; addrs[index] = item; return [...addrs]; }); setTransactionMemo(memo || transactionMemo); setIsLoading(false); setPayjoinUrl(pjUrl); }} onBarScanned={processAddressData} address={item.address} isLoading={isLoading} inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} launchedBy={name} editable={isEditable} /> {addresses.length > 1 && ( {loc.formatString(loc._.of, { number: index + 1, total: addresses.length })} )} ); }; if (isLoading || !wallet) { return ( ); } return ( setWidth(e.nativeEvent.layout.width)}> 1} data={addresses} renderItem={renderBitcoinTransactionInfoFields} ref={scrollView} horizontal pagingEnabled removeClippedSubviews={false} onMomentumScrollBegin={Keyboard.dismiss} onMomentumScrollEnd={handleRecipientsScrollEnds} onScroll={handleRecipientsScroll} scrollEventThrottle={200} scrollIndicatorInsets={styles.scrollViewIndicator} contentContainerStyle={styles.scrollViewContent} /> setIsFeeSelectionModalVisible(true)} disabled={isLoading} style={styles.fee} > {loc.send.create_fee} {networkTransactionFeesIsLoading ? ( ) : ( {feePrecalc.current ? formatFee(feePrecalc.current) : feeRate + ' ' + loc.units.sat_vbyte} )} {renderCreateButton()} {renderFeeSelectionModal()} {renderOptionsModal()} {Platform.select({ ios: 0} onUseAllPressed={onUseAllPressed} balance={allBalance} />, android: isAmountToolbarVisibleForAndroid && ( 0} onUseAllPressed={onUseAllPressed} balance={allBalance} /> ), })} {renderWalletSelectionOrCoinsSelected()} ); }; export default SendDetails; SendDetails.actionKeys = { SignPSBT: 'SignPSBT', SendMax: 'SendMax', AddRecipient: 'AddRecipient', RemoveRecipient: 'RemoveRecipient', AllowRBF: 'AllowRBF', ImportTransaction: 'ImportTransaction', ImportTransactionMultsig: 'ImportTransactionMultisig', ImportTransactionQR: 'ImportTransactionQR', CoinControl: 'CoinControl', CoSignTransaction: 'CoSignTransaction', }; SendDetails.actionIcons = { SignPSBT: { iconType: 'SYSTEM', iconValue: 'signature' }, SendMax: 'SendMax', AddRecipient: { iconType: 'SYSTEM', iconValue: 'person.badge.plus' }, RemoveRecipient: { iconType: 'SYSTEM', iconValue: 'person.badge.minus' }, AllowRBF: 'AllowRBF', ImportTransaction: { iconType: 'SYSTEM', iconValue: 'square.and.arrow.down' }, ImportTransactionMultsig: { iconType: 'SYSTEM', iconValue: 'square.and.arrow.down.on.square' }, ImportTransactionQR: { iconType: 'SYSTEM', iconValue: 'qrcode.viewfinder' }, CoinControl: { iconType: 'SYSTEM', iconValue: 'switch.2' }, }; const styles = StyleSheet.create({ loading: { flex: 1, paddingTop: 20, }, root: { flex: 1, justifyContent: 'space-between', }, scrollViewContent: { flexDirection: 'row', }, scrollViewIndicator: { top: 0, left: 8, bottom: 0, right: 8, }, modalContent: { padding: 22, borderTopLeftRadius: 16, borderTopRightRadius: 16, minHeight: 200, }, optionsContent: { padding: 22, borderTopLeftRadius: 16, borderTopRightRadius: 16, minHeight: 130, }, feeModalItem: { paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10, }, feeModalItemActive: { borderRadius: 8, }, feeModalRow: { justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', }, feeModalLabel: { fontSize: 22, fontWeight: '600', }, feeModalTime: { borderRadius: 5, paddingHorizontal: 6, paddingVertical: 3, }, feeModalCustom: { height: 60, alignItems: 'center', justifyContent: 'center', }, feeModalCustomText: { fontSize: 15, fontWeight: '600', }, createButton: { marginVertical: 16, marginHorizontal: 16, alignContent: 'center', minHeight: 44, }, select: { marginBottom: 24, marginHorizontal: 24, alignItems: 'center', }, selectTouch: { flexDirection: 'row', alignItems: 'center', }, selectText: { color: '#9aa0aa', fontSize: 14, marginRight: 8, }, selectWrap: { flexDirection: 'row', alignItems: 'center', marginVertical: 4, }, selectLabel: { fontSize: 14, }, of: { alignSelf: 'flex-end', marginRight: 18, marginVertical: 8, }, memo: { flexDirection: 'row', borderWidth: 1, borderBottomWidth: 0.5, minHeight: 44, height: 44, marginHorizontal: 20, alignItems: 'center', marginVertical: 8, borderRadius: 4, }, memoText: { flex: 1, marginHorizontal: 8, minHeight: 33, color: '#81868e', }, fee: { flexDirection: 'row', marginHorizontal: 20, justifyContent: 'space-between', alignItems: 'center', }, feeLabel: { fontSize: 14, }, feeRow: { minWidth: 40, height: 25, borderRadius: 4, justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, }, advancedOptions: { minWidth: 40, height: 40, justifyContent: 'center', }, frozenContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginVertical: 8, }, }); SendDetails.navigationOptions = navigationStyleTx({}, options => ({ ...options, title: loc.send.header, statusBarStyle: 'light', })); SendDetails.initialParams = { isEditable: true };