/* global alert */ import React, { Component } from 'react'; import { ActivityIndicator, View, TextInput, TouchableOpacity, KeyboardAvoidingView, Keyboard, TouchableWithoutFeedback, StyleSheet, Platform, Slider, Text, } from 'react-native'; import { Icon } from 'react-native-elements'; import { BlueNavigationStyle, BlueButton, BlueBitcoinAmount, BlueAddressInput } from '../../BlueComponents'; import PropTypes from 'prop-types'; import Modal from 'react-native-modal'; import NetworkTransactionFees, { NetworkTransactionFee } from '../../models/networkTransactionFees'; import BitcoinBIP70TransactionDecode from '../../bip70/bip70'; import { BitcoinUnit } from '../../models/bitcoinUnits'; import { HDLegacyP2PKHWallet, HDSegwitP2SHWallet } from '../../class'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; const bip21 = require('bip21'); let BigNumber = require('bignumber.js'); /** @type {AppStorage} */ let BlueApp = require('../../BlueApp'); let loc = require('../../loc'); let bitcoin = require('bitcoinjs-lib'); const btcAddressRx = /^[a-zA-Z0-9]{26,35}$/; export default class SendDetails extends Component { static navigationOptions = ({ navigation }) => ({ ...BlueNavigationStyle(navigation, true), title: loc.send.header, }); constructor(props) { super(props); console.log('props.navigation.state.params=', props.navigation.state.params); let address; let memo; if (props.navigation.state.params) address = props.navigation.state.params.address; if (props.navigation.state.params) memo = props.navigation.state.params.memo; let fromAddress; if (props.navigation.state.params) fromAddress = props.navigation.state.params.fromAddress; let fromSecret; if (props.navigation.state.params) fromSecret = props.navigation.state.params.fromSecret; let fromWallet = null; const wallets = BlueApp.getWallets(); for (let w of wallets) { if (w.getSecret() === fromSecret) { fromWallet = w; break; } if (w.getAddress() === fromAddress) { fromWallet = w; } } // fallback to first wallet if it exists if (!fromWallet && wallets[0]) fromWallet = wallets[0]; this.state = { isFeeSelectionModalVisible: false, fromAddress, fromWallet, fromSecret, isLoading: true, address, memo, fee: 1, networkTransactionFees: new NetworkTransactionFee(1, 1, 1), feeSliderValue: 1, bip70TransactionExpiration: null, }; } processAddressData = data => { this.setState( { isLoading: true }, () => { if (BitcoinBIP70TransactionDecode.matchesPaymentURL(data)) { this.processBIP70Invoice(data); } else { const dataWithoutSchema = data.replace('bitcoin:', ''); if (btcAddressRx.test(dataWithoutSchema) || dataWithoutSchema.indexOf('bc1') === 0) { this.setState({ address: data, bip70TransactionExpiration: null, isLoading: false, }); } else { let address, options; try { if (!data.toLowerCase().startsWith('bitcoin:')) { data = `bitcoin:${data}`; } const decoded = bip21.decode(data); address = decoded.address; options = decoded.options; } catch (error) { console.log(error); this.setState({ isLoading: false }); } console.log(options); if (btcAddressRx.test(address)) { this.setState({ address, amount: options.amount, memo: options.label || options.message, bip70TransactionExpiration: null, isLoading: false, }); } } } }, true, ); }; async componentDidMount() { let recommendedFees = await NetworkTransactionFees.recommendedFees().catch(response => { this.setState({ fee: response.halfHourFee, networkTransactionFees: response, feeSliderValue: response.halfHourFee, isLoading: false, }); }); if (recommendedFees) { this.setState({ fee: recommendedFees.halfHourFee, networkTransactionFees: recommendedFees, feeSliderValue: recommendedFees.halfHourFee, isLoading: false, }); if (this.props.navigation.state.params.uri) { if (BitcoinBIP70TransactionDecode.matchesPaymentURL(this.props.navigation.state.params.uri)) { this.processBIP70Invoice(this.props.navigation.state.params.uri); } else { try { const { address, amount, memo } = this.decodeBitcoinUri(this.props.navigation.getParam('uri')); this.setState({ address, amount, memo }); } catch (error) { console.log(error); alert('Error: Unable to decode Bitcoin address'); } } } } } decodeBitcoinUri(uri) { try { let amount = ''; let parsedBitcoinUri = null; let address = ''; let memo = ''; parsedBitcoinUri = bip21.decode(uri); address = parsedBitcoinUri.hasOwnProperty('address') ? parsedBitcoinUri.address : address; if (parsedBitcoinUri.hasOwnProperty('options')) { if (parsedBitcoinUri.options.hasOwnProperty('amount')) { amount = parsedBitcoinUri.options.amount.toString(); } if (parsedBitcoinUri.options.hasOwnProperty('label')) { memo = parsedBitcoinUri.options.label || memo; } } return { address, amount, memo }; } catch (_) { return undefined; } } recalculateAvailableBalance(balance, amount, fee) { if (!amount) amount = 0; if (!fee) fee = 0; let availableBalance; try { availableBalance = new BigNumber(balance); availableBalance = availableBalance.minus(amount); availableBalance = availableBalance.minus(fee); availableBalance = availableBalance.toString(10); } catch (err) { return balance; } return (availableBalance === 'NaN' && balance) || availableBalance; } calculateFee(utxos, txhex, utxoIsInSatoshis) { let index = {}; let c = 1; index[0] = 0; for (let utxo of utxos) { if (!utxoIsInSatoshis) { utxo.amount = new BigNumber(utxo.amount).multipliedBy(100000000).toNumber(); } index[c] = utxo.amount + index[c - 1]; c++; } let tx = bitcoin.Transaction.fromHex(txhex); let totalInput = index[tx.ins.length]; // ^^^ dumb way to calculate total input. we assume that signer uses utxos sequentially // so total input == sum of yongest used inputs (and num of used inputs is `tx.ins.length`) // TODO: good candidate to refactor and move to appropriate class. some day let totalOutput = 0; for (let o of tx.outs) { totalOutput += o.value * 1; } return new BigNumber(totalInput - totalOutput).dividedBy(100000000).toNumber(); } processBIP70Invoice(text) { try { if (BitcoinBIP70TransactionDecode.matchesPaymentURL(text)) { this.setState( { isLoading: true, }, () => { Keyboard.dismiss(); BitcoinBIP70TransactionDecode.decode(text) .then(response => { this.setState({ address: response.address, amount: loc.formatBalanceWithoutSuffix(response.amount, BitcoinUnit.BTC, false), memo: response.memo, fee: response.fee, bip70TransactionExpiration: response.expires, isLoading: false, }); }) .catch(error => { alert(error.errorMessage); this.setState({ isLoading: false, bip70TransactionExpiration: null }); }); }, ); } return true; } catch (error) { this.setState({ address: text.replace(' ', ''), isLoading: false, bip70TransactionExpiration: null, amount: 0 }); return false; } } async createTransaction() { this.setState({ isLoading: true }); let error = false; let requestedSatPerByte = this.state.fee.toString().replace(/\D/g, ''); console.log({ requestedSatPerByte }); if (!this.state.amount || this.state.amount === '0' || parseFloat(this.state.amount) === 0) { error = loc.send.details.amount_field_is_not_valid; console.log('validation error'); } else if (!this.state.fee || !requestedSatPerByte || parseFloat(requestedSatPerByte) < 1) { error = loc.send.details.fee_field_is_not_valid; console.log('validation error'); } else if (!this.state.address) { error = loc.send.details.address_field_is_not_valid; console.log('validation error'); } else if (this.recalculateAvailableBalance(this.state.fromWallet.getBalance(), this.state.amount, 0) < 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 (BitcoinBIP70TransactionDecode.isExpired(this.state.bip70TransactionExpiration)) { error = 'Transaction has expired.'; console.log('validation error'); } try { bitcoin.address.toOutputScript(this.state.address); } catch (err) { console.log('validation error'); console.log(err); error = loc.send.details.address_field_is_not_valid; } if (error) { this.setState({ isLoading: false }); alert(error); ReactNativeHapticFeedback.trigger('notificationError', false); return; } this.setState({ isLoading: true }, async () => { let utxo; let actualSatoshiPerByte; let tx, txid; let tries = 1; let fee = 0.000001; // initial fee guess try { await this.state.fromWallet.fetchUtxo(); if (this.state.fromWallet.getChangeAddressAsync) { await this.state.fromWallet.getChangeAddressAsync(); // to refresh internal pointer to next free address } if (this.state.fromWallet.getAddressAsync) { await this.state.fromWallet.getAddressAsync(); // to refresh internal pointer to next free address } utxo = this.state.fromWallet.utxo; do { console.log('try #', tries, 'fee=', fee); if (this.recalculateAvailableBalance(this.state.fromWallet.getBalance(), this.state.amount, fee) < 0) { // we could not add any fee. user is trying to send all he's got. that wont work throw new Error(loc.send.details.total_exceeds_balance); } let startTime = Date.now(); tx = this.state.fromWallet.createTx(utxo, this.state.amount, fee, this.state.address, this.state.memo); let endTime = Date.now(); console.log('create tx ', (endTime - startTime) / 1000, 'sec'); let txDecoded = bitcoin.Transaction.fromHex(tx); txid = txDecoded.getId(); console.log('txid', txid); console.log('txhex', tx); let feeSatoshi = new BigNumber(fee).multipliedBy(100000000); actualSatoshiPerByte = feeSatoshi.dividedBy(Math.round(tx.length / 2)); actualSatoshiPerByte = actualSatoshiPerByte.toNumber(); console.log({ satoshiPerByte: actualSatoshiPerByte }); if (Math.round(actualSatoshiPerByte) !== requestedSatPerByte * 1 || Math.floor(actualSatoshiPerByte) < 1) { console.log('fee is not correct, retrying'); fee = feeSatoshi .multipliedBy(requestedSatPerByte / actualSatoshiPerByte) .plus(10) .dividedBy(100000000) .toNumber(); } else { break; } } while (tries++ < 5); BlueApp.tx_metadata = BlueApp.tx_metadata || {}; BlueApp.tx_metadata[txid] = { txhex: tx, memo: this.state.memo, }; await BlueApp.saveToDisk(); } catch (err) { console.log(err); ReactNativeHapticFeedback.trigger('notificationError', false); alert(err); this.setState({ isLoading: false }); return; } this.setState({ isLoading: false }, () => this.props.navigation.navigate('Confirm', { amount: this.state.amount, // HD wallet's utxo is in sats, classic segwit wallet utxos are in btc fee: this.calculateFee( utxo, tx, this.state.fromWallet.type === HDSegwitP2SHWallet.type || this.state.fromWallet.type === HDLegacyP2PKHWallet.type, ), address: this.state.address, memo: this.state.memo, fromWallet: this.state.fromWallet, tx: tx, satoshiPerByte: actualSatoshiPerByte.toFixed(2), }), ); }); } onWalletSelect = wallet => { this.setState({ fromAddress: wallet.getAddress(), fromSecret: wallet.getSecret(), fromWallet: wallet }, () => this.props.navigation.goBack(null), ); }; renderFeeSelectionModal = () => { return ( { if (this.state.fee < 1 || this.state.feeSliderValue < 1) { this.setState({ fee: Number(1), feeSliderValue: Number(1) }); } this.setState({ isFeeSelectionModalVisible: false }); }} > this.textInput.focus()}> { this.textInput = ref; }} value={this.state.fee.toString()} onEndEditing={() => { if (this.state.fee < 1 || this.state.feeSliderValue < 1) { this.setState({ fee: Number(1), feeSliderValue: Number(1) }); } }} onChangeText={value => { let newValue = value.replace(/\D/g, ''); this.setState({ fee: Number(newValue), feeSliderValue: Number(newValue) }); }} maxLength={9} editable={!this.state.isLoading} placeholderTextColor="#37c0a1" placeholder={this.state.networkTransactionFees.halfHourFee.toString()} style={{ fontWeight: '600', color: '#37c0a1', marginBottom: 0, marginRight: 4, textAlign: 'right', fontSize: 36 }} /> sat/b {this.state.networkTransactionFees.fastestFee > 1 && ( this.setState({ feeSliderValue: value.toFixed(0), fee: value.toFixed(0) })} minimumValue={1} maximumValue={this.state.networkTransactionFees.fastestFee} value={Number(this.state.feeSliderValue)} maximumTrackTintColor="#d8d8d8" minimumTrackTintColor="#37c0a1" style={{ flex: 1 }} /> slow fast )} ); }; renderCreateButton = () => { return ( {this.state.isLoading ? ( ) : ( this.createTransaction()} title={loc.send.details.create} /> )} ); }; renderWalletSelectionButton = () => { return ( {!this.state.isLoading && ( this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect })} > {loc.wallets.select_wallet.toLowerCase()} )} {this.state.fromWallet.getLabel()} {this.state.fromWallet.getBalance()} {BitcoinUnit.BTC} ); }; render() { if (!this.state.fromWallet.getAddress) { return ( System error: Source wallet not found (this should never happen) ); } return ( this.setState({ amount: text })} /> { if (!this.processBIP70Invoice(text)) { this.setState({ address: text.trim().replace('bitcoin:', ''), isLoading: false, bip70TransactionExpiration: null, }); } else { try { const { address, amount, memo } = this.decodeBitcoinUri(text); this.setState({ address, amount, memo, isLoading: false, bip70TransactionExpiration: null }); } catch (_) { this.setState({ address: text.trim(), isLoading: false, bip70TransactionExpiration: null }); } } }} onBarScanned={this.processAddressData} address={this.state.address} isLoading={this.state.isLoading} /> this.setState({ memo: text })} placeholder={loc.send.details.note_placeholder} value={this.state.memo} numberOfLines={1} style={{ flex: 1, marginHorizontal: 8, minHeight: 33 }} editable={!this.state.isLoading} /> this.setState({ isFeeSelectionModalVisible: true })} disabled={this.state.isLoading} style={{ flexDirection: 'row', marginHorizontal: 20, justifyContent: 'space-between', alignItems: 'center' }} > Fee {this.state.fee} sat/b {this.renderCreateButton()} {this.renderFeeSelectionModal()} {this.renderWalletSelectionButton()} ); } } const styles = StyleSheet.create({ modalContent: { backgroundColor: '#FFFFFF', padding: 22, justifyContent: 'center', alignItems: 'center', borderTopLeftRadius: 16, borderTopRightRadius: 16, borderColor: 'rgba(0, 0, 0, 0.1)', minHeight: 200, height: 200, }, bottomModal: { justifyContent: 'flex-end', margin: 0, }, satoshisTextInput: { backgroundColor: '#d2f8d6', minWidth: 127, height: 60, borderRadius: 8, flexDirection: 'row', justifyContent: 'center', paddingHorizontal: 8, }, }); SendDetails.propTypes = { navigation: PropTypes.shape({ goBack: PropTypes.func, navigate: PropTypes.func, getParam: PropTypes.func, state: PropTypes.shape({ params: PropTypes.shape({ address: PropTypes.string, fromAddress: PropTypes.string, satoshiPerByte: PropTypes.string, fromSecret: PropTypes.fromSecret, memo: PropTypes.string, uri: PropTypes.string, }), }), }), };