/* global alert */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { ActivityIndicator, View, TextInput, Alert, StatusBar, TouchableOpacity, KeyboardAvoidingView, Keyboard, TouchableWithoutFeedback, StyleSheet, Dimensions, Platform, Text, LayoutAnimation, FlatList, } from 'react-native'; import { Icon } from 'react-native-elements'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { BlueCreateTxNavigationStyle, BlueButton, BlueBitcoinAmount, BlueAddressInput, BlueDismissKeyboardInputAccessory, BlueLoading, BlueUseAllFundsButton, BlueListItem, BlueText, } from '../../BlueComponents'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import BigNumber from 'bignumber.js'; import RNFS from 'react-native-fs'; import * as bitcoin from 'bitcoinjs-lib'; import NetworkTransactionFees, { NetworkTransactionFee } from '../../models/networkTransactionFees'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; import { HDSegwitBech32Wallet, LightningCustodianWallet, MultisigHDWallet, WatchOnlyWallet } from '../../class'; import { BitcoinTransaction } from '../../models/bitcoinTransactionInfo'; import DocumentPicker from 'react-native-document-picker'; import DeeplinkSchemaMatch from '../../class/deeplink-schema-match'; import loc, { formatBalanceWithoutSuffix } from '../../loc'; import { BlueCurrentTheme } from '../../components/themes'; import CoinsSelected from '../../components/CoinsSelected'; import BottomModal from '../../components/BottomModal'; import { AbstractHDElectrumWallet } from '../../class/wallets/abstract-hd-electrum-wallet'; import { BlueStorageContext } from '../../blue_modules/storage-context'; const currency = require('../../blue_modules/currency'); const prompt = require('../../blue_modules/prompt'); const fs = require('../../blue_modules/fs'); const btcAddressRx = /^[a-zA-Z0-9]{26,35}$/; const styles = StyleSheet.create({ loading: { flex: 1, paddingTop: 20, backgroundColor: BlueCurrentTheme.colors.background, }, root: { flex: 1, justifyContent: 'space-between', backgroundColor: BlueCurrentTheme.colors.elevated, }, scrollViewContent: { flexDirection: 'row', }, modalContent: { backgroundColor: BlueCurrentTheme.colors.modal, padding: 22, borderTopLeftRadius: 16, borderTopRightRadius: 16, borderTopColor: BlueCurrentTheme.colors.borderTopColor, borderWidth: BlueCurrentTheme.colors.borderWidth, minHeight: 200, }, advancedTransactionOptionsModalContent: { backgroundColor: BlueCurrentTheme.colors.modal, padding: 22, borderTopLeftRadius: 16, borderTopRightRadius: 16, borderTopColor: BlueCurrentTheme.colors.borderTopColor, borderWidth: BlueCurrentTheme.colors.borderWidth, minHeight: 130, }, feeModalItem: { paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10, }, feeModalItemActive: { borderRadius: 8, backgroundColor: BlueCurrentTheme.colors.feeLabel, }, feeModalRow: { justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', }, feeModalLabel: { fontSize: 22, color: BlueCurrentTheme.colors.successColor, fontWeight: '600', }, feeModalTime: { backgroundColor: BlueCurrentTheme.colors.successColor, borderRadius: 5, paddingHorizontal: 6, paddingVertical: 3, }, feeModalTimeText: { color: BlueCurrentTheme.colors.background, }, feeModalValue: { color: BlueCurrentTheme.colors.successColor, }, feeModalCustom: { height: 60, alignItems: 'center', justifyContent: 'center', }, feeModalCustomText: { color: BlueCurrentTheme.colors.buttonAlternativeTextColor, 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: { color: BlueCurrentTheme.colors.buttonTextColor, fontSize: 14, }, of: { alignSelf: 'flex-end', marginRight: 18, marginVertical: 8, color: BlueCurrentTheme.colors.feeText, }, memo: { flexDirection: 'row', borderColor: BlueCurrentTheme.colors.formBorder, borderBottomColor: BlueCurrentTheme.colors.formBorder, borderWidth: 1, borderBottomWidth: 0.5, backgroundColor: BlueCurrentTheme.colors.inputBackgroundColor, 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: { color: BlueCurrentTheme.colors.feeText, fontSize: 14, }, feeRow: { backgroundColor: BlueCurrentTheme.colors.feeLabel, minWidth: 40, height: 25, borderRadius: 4, justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, }, feeValue: { color: BlueCurrentTheme.colors.feeValue, }, }); export default class SendDetails extends Component { static contextType = BlueStorageContext; state = { isLoading: true }; scrollView = React.createRef(); constructor(props, context) { super(props); this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this._keyboardDidShow); this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide); /** @type {LegacyWallet} */ let fromWallet = null; if (props.route.params) fromWallet = props.route.params.fromWallet; const wallets = context.wallets.filter(wallet => wallet.type !== LightningCustodianWallet.type && wallet.allowSend()); if (wallets.length === 0) { alert(loc.send.details_wallet_before_tx); return props.navigation.goBack(null); } else { if (!fromWallet && wallets.length > 0) { fromWallet = wallets[0]; } this.state = { isLoading: false, showSendMax: false, isFeeSelectionModalVisible: false, isAdvancedTransactionOptionsVisible: false, isTransactionReplaceable: fromWallet.type === HDSegwitBech32Wallet.type, recipientsScrollIndex: 0, fromWallet, addresses: [], units: [], memo: '', networkTransactionFees: new NetworkTransactionFee(1, 1, 1), fee: '1', feePrecalc: { current: null, slowFee: null, mediumFee: null, fastestFee: null, }, feeUnit: fromWallet.getPreferredBalanceUnit(), amountUnit: fromWallet.preferredBalanceUnit, // default for whole screen renderWalletSelectionButtonHidden: false, width: Dimensions.get('window').width, utxo: null, }; } } renderNavigationHeader() { this.props.navigation.setParams({ withAdvancedOptionsMenuButton: this.state.fromWallet.allowBatchSend() || this.state.fromWallet.allowSendMax(), advancedOptionsMenuButtonAction: () => { Keyboard.dismiss(); this.setState({ isAdvancedTransactionOptionsVisible: true }); }, }); } /** * 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 */ processAddressData = data => { this.setState({ isLoading: true }, async () => { const recipients = this.state.addresses; const dataWithoutSchema = data.replace('bitcoin:', '').replace('BITCOIN:', ''); if (this.state.fromWallet.isAddressValid(dataWithoutSchema)) { recipients[[this.state.recipientsScrollIndex]].address = dataWithoutSchema; const units = this.state.units; units[this.state.recipientsScrollIndex] = this.state.amountUnit; this.setState({ address: recipients, isLoading: false, units, }); } else { 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; this.setState({ isLoading: false }); } console.log(options); if (btcAddressRx.test(address) || address.indexOf('bc1') === 0 || address.indexOf('BC1') === 0) { const units = this.state.units; units[this.state.recipientsScrollIndex] = BitcoinUnit.BTC; // also resetting current unit to BTC recipients[[this.state.recipientsScrollIndex]].address = address; recipients[[this.state.recipientsScrollIndex]].amount = options.amount; recipients[[this.state.recipientsScrollIndex]].amountSats = new BigNumber(options.amount).multipliedBy(100000000).toNumber(); this.setState({ addresses: recipients, memo: options.label || options.message, isLoading: false, amountUnit: BitcoinUnit.BTC, units, payjoinUrl: options.pj || '', }); } else { this.setState({ isLoading: false }); } } }); }; async componentDidMount() { console.log('send/details - componentDidMount'); if (!this.state.fromWallet) return; this.renderNavigationHeader(); this.context.setSelectedWallet(this.state.fromWallet.getID()); /** @type {BitcoinTransaction[]} */ const addresses = []; let initialMemo = ''; if (this.props.route.params.uri) { const uri = this.props.route.params.uri; try { const { address, amount, memo, payjoinUrl } = DeeplinkSchemaMatch.decodeBitcoinUri(uri); addresses.push(new BitcoinTransaction(address, amount, currency.btcToSatoshi(amount))); initialMemo = memo; this.setState({ addresses, memo: initialMemo, isLoading: false, amountUnit: BitcoinUnit.BTC, payjoinUrl }); } catch (error) { console.log(error); alert(loc.send.details_error_decode); } } else if (this.props.route.params.address) { addresses.push(new BitcoinTransaction(this.props.route.params.address)); if (this.props.route.params.memo) initialMemo = this.props.route.params.memo; this.setState({ addresses, memo: initialMemo, isLoading: false, amountUnit: BitcoinUnit.BTC }); } else { this.setState({ addresses: [new BitcoinTransaction()], isLoading: false }); } try { const cachedNetworkTransactionFees = JSON.parse(await AsyncStorage.getItem(NetworkTransactionFee.StorageKey)); if (cachedNetworkTransactionFees && 'fastestFee' in cachedNetworkTransactionFees) { this.setState({ fee: cachedNetworkTransactionFees.fastestFee.toString(), networkTransactionFees: cachedNetworkTransactionFees, }); } } catch (_) {} this.reCalcTx(); try { const recommendedFees = await Promise.race([NetworkTransactionFees.recommendedFees(), this.context.sleep(2000)]); if (recommendedFees && 'fastestFee' in recommendedFees) { await AsyncStorage.setItem(NetworkTransactionFee.StorageKey, JSON.stringify(recommendedFees)); this.setState({ fee: recommendedFees.fastestFee.toString(), networkTransactionFees: recommendedFees, }); } } catch (_) {} // either sleep expired or recommendedFees threw an exception if (this.props.route.params.uri) { try { const { address, amount, memo, payjoinUrl } = DeeplinkSchemaMatch.decodeBitcoinUri(this.props.route.params.uri); this.setState({ address, amount, memo, isLoading: false, payjoinUrl }); } catch (error) { console.log(error); this.setState({ isLoading: false }); alert(loc.send.details_error_decode); } } try { await Promise.race([this.state.fromWallet.fetchUtxo(), this.context.sleep(6000)]); } catch (_) { // either sleep expired or fetchUtxo threw an exception } this.setState({ isLoading: false }); this.reCalcTx(); } componentWillUnmount() { this.keyboardDidShowListener.remove(); this.keyboardDidHideListener.remove(); } _keyboardDidShow = () => { this.setState({ renderWalletSelectionOrCoinsSelectedHidden: true, isAmountToolbarVisibleForAndroid: true }); }; _keyboardDidHide = () => { this.setState({ renderWalletSelectionOrCoinsSelectedHidden: false, isAmountToolbarVisibleForAndroid: false }); }; async createTransaction() { Keyboard.dismiss(); this.setState({ isLoading: true }); const requestedSatPerByte = this.state.fee; for (const [index, transaction] of this.state.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 (!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 (this.state.fromWallet.getBalance() - transaction.amountSats < 0) { // first sanity check is that sending amount is not bigger than available balance error = 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 = 'This address appears to be for a Lightning invoice. Please, go to your Lightning wallet in order to make a payment for this invoice.'; console.log('validation error'); } } if (!error) { try { bitcoin.address.toOutputScript(transaction.address); } catch (err) { console.log('validation error'); console.log(err); error = loc.send.details_address_field_is_not_valid; } } if (error) { this.scrollView.current.scrollToIndex({ index }); this.setState({ isLoading: false }); alert(error); ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); return; } } try { await this.createPsbtTransaction(); } catch (Err) { this.setState({ isLoading: false }, () => { alert(Err.message); ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); }); } } getChangeAddressFast() { if (this.state.changeAddress) return this.state.changeAddress; // cache /** @type {AbstractHDElectrumWallet|WatchOnlyWallet} */ const wallet = this.state.fromWallet; let changeAddress; if (WatchOnlyWallet.type === wallet.type && !wallet.isHd()) { // plain watchonly - just get the address changeAddress = wallet.getAddress(); } else if (WatchOnlyWallet.type === wallet.type || wallet instanceof AbstractHDElectrumWallet) { changeAddress = wallet._getInternalAddressByIndex(wallet.getNextFreeChangeAddressIndex()); } else { // legacy wallets changeAddress = wallet.getAddress(); } return changeAddress; } async getChangeAddressAsync() { if (this.state.changeAddress) return this.state.changeAddress; // cache /** @type {AbstractHDElectrumWallet|WatchOnlyWallet} */ const wallet = this.state.fromWallet; let changeAddress; if (WatchOnlyWallet.type === wallet.type && !wallet.isHd()) { // plain watchonly - just get the address changeAddress = wallet.getAddress(); } else { // otherwise, lets call widely-used getChangeAddressAsync() try { changeAddress = await Promise.race([this.context.sleep(2000), wallet.getChangeAddressAsync()]); } catch (_) {} if (!changeAddress) { // either sleep expired or getChangeAddressAsync threw an exception if (wallet instanceof AbstractHDElectrumWallet) { changeAddress = wallet._getInternalAddressByIndex(wallet.getNextFreeChangeAddressIndex()); } else { // legacy wallets changeAddress = wallet.getAddress(); } } } if (changeAddress) this.setState({ changeAddress }); // cache return changeAddress; } /** * Recalculating fee options by creating skeleton of future tx. */ reCalcTx = (all = false) => { const wallet = this.state.fromWallet; const fees = this.state.networkTransactionFees; const changeAddress = this.getChangeAddressFast(); const requestedSatPerByte = Number(this.state.fee); const feePrecalc = { ...this.state.feePrecalc }; const utxo = this.state.utxo || wallet.getUtxo(); const options = all ? [ { key: 'current', fee: requestedSatPerByte }, { key: 'slowFee', fee: fees.slowFee }, { key: 'mediumFee', fee: fees.mediumFee }, { key: 'fastestFee', fee: fees.fastestFee }, ] : [{ key: 'current', fee: requestedSatPerByte }]; for (const opt of options) { let targets = []; for (const transaction of this.state.addresses) { if (transaction.amount === BitcoinUnit.MAX) { // single output with MAX targets = [{ address: transaction.address }]; break; } const value = parseInt(transaction.amountSats); 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) }); } } } // replace wrong addresses with dump targets = targets.map(t => { try { bitcoin.address.toOutputScript(t.address); return t; } catch (e) { return { ...t, address: '36JxaUrpDzkEerkTf1FzwHNE1Hb7cCjgJV' }; } }); let flag = false; while (true) { try { const { fee } = wallet.coinselect( utxo, targets, opt.fee, changeAddress, this.state.isTransactionReplaceable ? HDSegwitBech32Wallet.defaultRBFSequence : HDSegwitBech32Wallet.finalRBFSequence, ); feePrecalc[opt.key] = fee; break; } catch (e) { if (e.message.includes('Not enough') && !flag) { flag = true; // if the outputs are too big, replace them with dust targets = targets.map(t => ({ ...t, value: 546 })); continue; } feePrecalc[opt.key] = null; break; } } } this.setState({ feePrecalc }); }; async createPsbtTransaction() { /** @type {HDSegwitBech32Wallet} */ const wallet = this.state.fromWallet; const changeAddress = await this.getChangeAddressAsync(); const requestedSatPerByte = Number(this.state.fee); const utxo = this.state.utxo || wallet.getUtxo(); console.log({ requestedSatPerByte, utxo }); let targets = []; for (const transaction of this.state.addresses) { if (transaction.amount === BitcoinUnit.MAX) { // single output with MAX targets = [{ address: transaction.address }]; break; } const value = parseInt(transaction.amountSats); 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, fee, psbt } = wallet.createTransaction( utxo, targets, requestedSatPerByte, changeAddress, this.state.isTransactionReplaceable ? HDSegwitBech32Wallet.defaultRBFSequence : HDSegwitBech32Wallet.finalRBFSequence, ); 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 this.props.navigation.navigate('PsbtWithHardwareWallet', { memo: this.state.memo, fromWallet: wallet, psbt, }); this.setState({ isLoading: false }); return; } if (wallet.type === MultisigHDWallet.type) { this.props.navigation.navigate('PsbtMultisig', { memo: this.state.memo, psbtBase64: psbt.toBase64(), walletID: wallet.getID(), }); this.setState({ isLoading: false }); return; } this.context.txMetadata[tx.getId()] = { txhex: tx.toHex(), memo: this.state.memo, }; await this.context.saveToDisk(); const recipients = outputs.filter(({ address }) => address !== changeAddress); this.props.navigation.navigate('Confirm', { fee: new BigNumber(fee).dividedBy(100000000).toNumber(), memo: this.state.memo, fromWallet: wallet, tx: tx.toHex(), recipients, satoshiPerByte: requestedSatPerByte, payjoinUrl: this.state.payjoinUrl, psbt, }); this.setState({ isLoading: false }); } onUTXOChoose = utxo => { this.setState({ utxo }, this.reCalcTx); }; onWalletSelect = wallet => { const changeWallet = () => { this.setState({ fromWallet: wallet, utxo: null }, () => { this.renderNavigationHeader(); this.context.setSelectedWallet(wallet.getID()); this.props.navigation.pop(); }); }; if (this.state.addresses.length > 1 && !wallet.allowBatchSend()) { ReactNativeHapticFeedback.trigger('notificationWarning'); Alert.alert( loc.send.details_wallet_selection, loc.send.details_no_multiple, [ { text: loc._.ok, onPress: async () => { const firstTransaction = this.state.addresses.find(element => { const feeSatoshi = new BigNumber(element.amount).multipliedBy(100000000); return element.address.length > 0 && feeSatoshi > 0; }) || this.state.addresses[0]; LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); this.setState({ addresses: [firstTransaction] }, () => changeWallet()); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); } else if (this.state.addresses.some(element => element.amount === BitcoinUnit.MAX) && !wallet.allowSendMax()) { ReactNativeHapticFeedback.trigger('notificationWarning'); Alert.alert( loc.send.details_wallet_selection, loc.send.details_no_maximum, [ { text: loc._.ok, onPress: async () => { const firstTransaction = this.state.addresses.find(element => { return element.amount === BitcoinUnit.MAX; }) || this.state.addresses[0]; firstTransaction.amount = 0; LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); this.setState({ addresses: [firstTransaction] }, () => changeWallet()); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); } else { changeWallet(); } }; hideFeeSelectionModal = () => this.setState({ isFeeSelectionModalVisible: false }); renderFeeSelectionModal = () => { const { feePrecalc, fee, networkTransactionFees: nf } = this.state; const options = [ { label: loc.send.fee_fast, time: loc.send.fee_10m, fee: feePrecalc.fastestFee, rate: nf.fastestFee, active: Number(fee) === nf.fastestFee, }, { label: loc.send.fee_medium, time: loc.send.fee_3h, fee: feePrecalc.mediumFee, rate: nf.mediumFee, active: Number(fee) === nf.mediumFee, }, { label: loc.send.fee_slow, time: loc.send.fee_1d, fee: feePrecalc.slowFee, rate: nf.slowFee, active: Number(fee) === nf.slowFee, }, ]; return ( {options.map(({ label, time, fee, rate, active }, index) => ( this.setState(({ feePrecalc }) => { feePrecalc.current = fee; return { isFeeSelectionModalVisible: false, fee: rate.toString(), feePrecalc }; }) } style={[styles.feeModalItem, active && styles.feeModalItemActive]} > {label} ~{time} {fee && this.formatFee(fee)} {rate} sat/byte ))} { let error = loc.send.fee_satbyte; 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 this.setState({ fee, isFeeSelectionModalVisible: false }, this.reCalcTx); return; } }} > {loc.send.fee_custom} ); }; /** * same as `importTransaction`, but opens camera instead. * * @returns {Promise} */ importQrTransaction = async () => { if (this.state.fromWallet.type !== WatchOnlyWallet.type) { alert('Error: importing transaction in non-watchonly wallet (this should never happen)'); return; } this.setState({ isAdvancedTransactionOptionsVisible: false }); this.props.navigation.navigate('ScanQRCodeRoot', { screen: 'ScanQRCode', params: { onBarScanned: this.importQrTransactionOnBarScanned, showFileImportButton: false, }, }); }; importQrTransactionOnBarScanned = async ret => { this.props.navigation.dangerouslyGetParent().pop(); if (!ret.data) ret = { data: ret }; if (ret.data.toUpperCase().startsWith('UR')) { alert('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); this.props.navigation.navigate('PsbtWithHardwareWallet', { memo: this.state.memo, fromWallet: this.state.fromWallet, psbt, }); this.setState({ isLoading: false, isAdvancedTransactionOptionsVisible: 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} */ importTransaction = async () => { if (this.state.fromWallet.type !== WatchOnlyWallet.type) { alert('Error: importing transaction in non-watchonly wallet (this should never happen)'); return; } try { const res = await DocumentPicker.pick({ 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(); this.props.navigation.navigate('PsbtWithHardwareWallet', { memo: this.state.memo, fromWallet: this.state.fromWallet, txhex, }); this.setState({ isLoading: false, isAdvancedTransactionOptionsVisible: false }); } else 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); this.props.navigation.navigate('PsbtWithHardwareWallet', { memo: this.state.memo, fromWallet: this.state.fromWallet, psbt, }); this.setState({ isLoading: false, isAdvancedTransactionOptionsVisible: false }); } else 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', ''); this.props.navigation.navigate('PsbtWithHardwareWallet', { memo: this.state.memo, fromWallet: this.state.fromWallet, txhex: file, }); this.setState({ isLoading: false, isAdvancedTransactionOptionsVisible: false }); } else { alert('Unrecognized file format'); } } catch (err) { if (!DocumentPicker.isCancel(err)) { alert(loc.send.details_no_signed_tx); } } }; 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 }, ); }); }; _importTransactionMultisig = async base64arg => { try { /** @type MultisigHDWallet */ const fromWallet = this.state.fromWallet; const base64 = base64arg || (await fs.openSignedTransaction()); if (!base64) return; const psbt = bitcoin.Psbt.fromBase64(base64); // if it doesnt throw - all good, its valid if (fromWallet.howManySignaturesCanWeMake() > 0 && (await this.askCosignThisTransaction())) { fromWallet.cosignPsbt(psbt); } this.props.navigation.navigate('PsbtMultisig', { memo: this.state.memo, psbtBase64: psbt.toBase64(), walletID: fromWallet.getID(), }); } catch (error) { alert(loc.send.problem_with_psbt + ': ' + error.message); } this.setState({ isLoading: false, isAdvancedTransactionOptionsVisible: false }); }; importTransactionMultisig = async () => { return this._importTransactionMultisig(); }; onBarScanned = ret => { this.props.navigation.dangerouslyGetParent().pop(); if (!ret.data) ret = { data: ret }; if (ret.data.toUpperCase().startsWith('UR')) { alert('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 this._importTransactionMultisig(ret.data); } }; importTransactionMultisigScanQr = async () => { this.setState({ isAdvancedTransactionOptionsVisible: false }); this.props.navigation.navigate('ScanQRCodeRoot', { screen: 'ScanQRCode', params: { onBarScanned: this.onBarScanned, showFileImportButton: true, }, }); }; handleAddRecipient = () => { const { addresses } = this.state; addresses.push(new BitcoinTransaction()); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut, () => this.scrollView.current.scrollToEnd()); this.setState( { addresses, isAdvancedTransactionOptionsVisible: false, }, () => { this.scrollView.current.scrollToEnd(); if (this.state.addresses.length > 1) this.scrollView.current.flashScrollIndicators(); // after adding recipient it automatically scrolls to the last one this.setState({ recipientsScrollIndex: this.state.addresses.length - 1 }); }, ); }; handleRemoveRecipient = () => { const { addresses } = this.state; addresses.splice(this.state.recipientsScrollIndex, 1); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); this.setState( { addresses, isAdvancedTransactionOptionsVisible: false, }, () => { if (this.state.addresses.length > 1) this.scrollView.current.flashScrollIndicators(); // after deletion it automatically scrolls to the last one this.setState({ recipientsScrollIndex: this.state.addresses.length - 1 }); }, ); }; handleCoinControl = () => { this.setState( { isAdvancedTransactionOptionsVisible: false, }, () => { const { fromWallet } = this.state; this.props.navigation.navigate('CoinControl', { walletId: fromWallet.getID(), onUTXOChoose: this.onUTXOChoose, }); }, ); }; hideAdvancedTransactionOptionsModal = () => { Keyboard.dismiss(); this.setState({ isAdvancedTransactionOptionsVisible: false }); }; renderAdvancedTransactionOptionsModal = () => { const isSendMaxUsed = this.state.addresses.some(element => element.amount === BitcoinUnit.MAX); return ( {this.state.fromWallet.allowSendMax() && ( 0) || isSendMaxUsed} title={loc.send.details_adv_full} hideChevron component={TouchableOpacity} onPress={this.onUseAllPressed} /> )} {this.state.fromWallet.type === HDSegwitBech32Wallet.type && ( )} {this.state.fromWallet.type === WatchOnlyWallet.type && this.state.fromWallet.isHd() && this.state.fromWallet.getSecret().startsWith('zpub') && ( )} {this.state.fromWallet.type === WatchOnlyWallet.type && this.state.fromWallet.isHd() && this.state.fromWallet.getSecret().startsWith('zpub') && ( )} {this.state.fromWallet.type === MultisigHDWallet.type && ( )} {this.state.fromWallet.type === MultisigHDWallet.type && this.state.fromWallet.howManySignaturesCanWeMake() > 0 && ( )} {this.state.fromWallet.allowBatchSend() && ( <> )} ); }; onReplaceableFeeSwitchValueChanged = value => { this.setState({ isTransactionReplaceable: value }); }; scrollViewCurrentIndex = () => { Keyboard.dismiss(); const offset = this.scrollView.current.contentOffset; if (offset) { const page = Math.round(offset.x / Dimensions.get('window').width); return page; } return 0; }; renderCreateButton = () => { return ( {this.state.isLoading ? ( ) : ( this.createTransaction()} title={loc.send.details_next} testID="CreateTransactionButton" /> )} ); }; renderWalletSelectionOrCoinsSelected = () => { if (this.state.renderWalletSelectionOrCoinsSelectedHidden) return; if (this.state.utxo !== null) { return ( { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); this.setState({ utxo: null }, this.reCalcTx); }} /> ); } return ( {!this.state.isLoading && ( this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect, chainType: Chain.ONCHAIN }) } > {loc.wallets.select_wallet.toLowerCase()} )} this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect, chainType: Chain.ONCHAIN }) } > {this.state.fromWallet.getLabel()} ); }; renderBitcoinTransactionInfoFields = ({ item, index }) => { return ( { const units = this.state.units; units[index] = unit; const addresses = this.state.addresses; const item = addresses[index]; switch (unit) { case BitcoinUnit.SATS: item.amountSats = parseInt(item.amount); break; case BitcoinUnit.BTC: item.amountSats = currency.btcToSatoshi(item.amount); break; case BitcoinUnit.LOCAL_CURRENCY: // also accounting for cached fiat->sat conversion to avoid rounding error item.amountSats = BlueBitcoinAmount.getCachedSatoshis(item.amount) || currency.btcToSatoshi(currency.fiatToBTC(item.amount)); break; } addresses[index] = item; this.setState({ units, addresses }); }} onChangeText={text => { item.amount = text; switch (this.state.units[index] || this.state.amountUnit) { case BitcoinUnit.BTC: item.amountSats = currency.btcToSatoshi(item.amount); break; case BitcoinUnit.LOCAL_CURRENCY: item.amountSats = currency.btcToSatoshi(currency.fiatToBTC(item.amount)); break; default: case BitcoinUnit.SATS: item.amountSats = parseInt(text); break; } const addresses = this.state.addresses; addresses[index] = item; this.setState({ addresses }, this.reCalcTx); }} unit={this.state.units[index] || this.state.amountUnit} inputAccessoryViewID={this.state.fromWallet.allowSendMax() ? BlueUseAllFundsButton.InputAccessoryViewID : null} /> { text = text.trim(); const transactions = this.state.addresses; const { address, amount, memo, payjoinUrl } = DeeplinkSchemaMatch.decodeBitcoinUri(text); item.address = address || text; item.amount = amount || item.amount; transactions[index] = item; this.setState({ addresses: transactions, memo: memo || this.state.memo, isLoading: false, payjoinUrl, }); this.reCalcTx(); }} onBarScanned={this.processAddressData} address={item.address} isLoading={this.state.isLoading} inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} launchedBy={this.props.route.name} /> {this.state.addresses.length > 1 && ( {loc.formatString(loc._.of, { number: index + 1, total: this.state.addresses.length })} )} ); }; onUseAllPressed = () => { ReactNativeHapticFeedback.trigger('notificationWarning'); Alert.alert( loc.send.details_adv_full, loc.send.details_adv_full_sure + ' ' + (this.state.addresses.length > 1 ? loc.send.details_adv_full_remove : ''), [ { text: loc._.ok, onPress: async () => { Keyboard.dismiss(); const recipient = this.state.addresses[this.scrollViewCurrentIndex()]; recipient.amount = BitcoinUnit.MAX; recipient.amountSats = BitcoinUnit.MAX; LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); this.setState({ addresses: [recipient], units: [BitcoinUnit.BTC], isAdvancedTransactionOptionsVisible: false, }); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); }; formatFee = fee => { switch (this.state.feeUnit) { case BitcoinUnit.SATS: return fee + ' ' + BitcoinUnit.SATS; case BitcoinUnit.BTC: return currency.satoshiToBTC(fee) + ' ' + BitcoinUnit.BTC; case BitcoinUnit.LOCAL_CURRENCY: return currency.satoshiToLocalCurrency(fee); } }; onLayout = e => { this.setState({ width: e.nativeEvent.layout.width }); }; keyExtractor = (_item, index) => `${index}`; render() { const { fromWallet, utxo } = this.state; if (this.state.isLoading || typeof fromWallet === 'undefined') { return ( ); } // if utxo is limited we use it to calculate available balance const balance = utxo ? utxo.reduce((prev, curr) => prev + curr.value, 0) : fromWallet.getBalance(); const allBalance = formatBalanceWithoutSuffix(balance, BitcoinUnit.BTC, true); return ( 1} extraData={this.state.addresses} data={this.state.addresses} renderItem={this.renderBitcoinTransactionInfoFields} keyExtractor={this.keyExtractor} ref={this.scrollView} horizontal pagingEnabled removeClippedSubviews={false} onMomentumScrollBegin={Keyboard.dismiss} scrollIndicatorInsets={{ top: 0, left: 8, bottom: 0, right: 8 }} contentContainerStyle={styles.scrollViewContent} /> this.setState({ memo: text })} placeholder={loc.send.details_note_placeholder} placeholderTextColor="#81868e" value={this.state.memo} numberOfLines={1} style={styles.memoText} editable={!this.state.isLoading} onSubmitEditing={Keyboard.dismiss} inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} /> this.setState({ isFeeSelectionModalVisible: true }, () => this.reCalcTx(true))} disabled={this.state.isLoading} style={styles.fee} > {loc.send.create_fee} {this.state.feePrecalc.current ? this.formatFee(this.state.feePrecalc.current) : this.state.fee + ' sat/byte'} {this.renderCreateButton()} {this.renderFeeSelectionModal()} {this.renderAdvancedTransactionOptionsModal()} {Platform.select({ ios: ( 0} onUseAllPressed={this.onUseAllPressed} balance={allBalance} /> ), android: this.state.isAmountToolbarVisibleForAndroid && ( 0} onUseAllPressed={this.onUseAllPressed} balance={allBalance} /> ), })} {this.renderWalletSelectionOrCoinsSelected()} ); } } SendDetails.propTypes = { navigation: PropTypes.shape({ pop: PropTypes.func, goBack: PropTypes.func, navigate: PropTypes.func, setParams: PropTypes.func, dangerouslyGetParent: PropTypes.func, }), route: PropTypes.shape({ name: PropTypes.string, params: PropTypes.shape({ amount: PropTypes.number, address: PropTypes.string, satoshiPerByte: PropTypes.string, fromWallet: PropTypes.fromWallet, memo: PropTypes.string, uri: PropTypes.string, }), }), }; SendDetails.navigationOptions = ({ navigation, route }) => ({ ...BlueCreateTxNavigationStyle(navigation, route.params.withAdvancedOptionsMenuButton, route.params.advancedOptionsMenuButtonAction), title: loc.send.header, });