/* global alert */ import React, { Component } from 'react'; import { ActivityIndicator, View, TextInput, TouchableOpacity, KeyboardAvoidingView, Keyboard, TouchableWithoutFeedback, StyleSheet, Platform, Slider, } from 'react-native'; import { Text, Icon } from 'react-native-elements'; import { BlueNavigationStyle, BlueButton } 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 { HDSegwitP2SHWallet } from '../../class'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; const bip21 = require('bip21'); let EV = require('../../events'); let BigNumber = require('bignumber.js'); /** @type {AppStorage} */ let BlueApp = require('../../BlueApp'); let loc = require('../../loc'); let bitcoin = require('bitcoinjs-lib'); let currency = require('../../currency'); 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 startTime = Date.now(); let address; if (props.navigation.state.params) address = props.navigation.state.params.address; let memo = false; 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(); let startTime2 = Date.now(); 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]; let amount = ''; let parsedBitcoinUri = null; if (props.navigation.state.params.uri) { try { parsedBitcoinUri = bip21.decode(props.navigation.state.params.uri); address = parsedBitcoinUri.address ? parsedBitcoinUri.address : address; amount = parsedBitcoinUri.options.amount ? parsedBitcoinUri.options.amount : amount; memo = parsedBitcoinUri.options.label ? parsedBitcoinUri.options.label : memo; } catch (error) { console.error(error); alert('Error: Unable to decode Bitcoin address'); } } let endTime2 = Date.now(); console.log('getAddress() took', (endTime2 - startTime2) / 1000, 'sec'); console.log({ memo }); this.state = { isFeeSelectionModalVisible: false, fromAddress: fromAddress, fromWallet: fromWallet, fromSecret: fromSecret, isLoading: true, address: address, amount, memo, fee: 1, networkTransactionFees: new NetworkTransactionFee(1, 1, 1), feeSliderValue: 1, bip70TransactionExpiration: null, }; let endTime = Date.now(); console.log('constructor took', (endTime - startTime) / 1000, 'sec'); } async componentDidMount() { EV( EV.enum.CREATE_TRANSACTION_NEW_DESTINATION_ADDRESS, data => { if (btcAddressRx.test(data) || data.indexOf('bc1') === 0) { this.setState({ address: data, bip70TransactionExpiration: null, }); } else { let address, options; try { const decoded = bip21.decode(data); address = decoded.address; options = decoded.options; } catch (Err) { console.log(Err); } console.log(options); if (btcAddressRx.test(address)) { this.setState({ address, amount: options.amount, memo: options.label || options.message, bip70TransactionExpiration: null, }); } else if (BitcoinBIP70TransactionDecode.matchesPaymentURL(data)) { BitcoinBIP70TransactionDecode.decode(data) .then(response => { this.setState({ address: response.address, amount: loc.formatBalanceWithoutSuffix(response.amount, BitcoinUnit.BTC), memo: response.memo, fee: response.fee, bip70TransactionExpiration: response.expires, }); }) .catch(error => alert(error.errorMessage)); } } }, true, ); 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, }); } let startTime = Date.now(); console.log('send/details - componentDidMount'); let endTime = Date.now(); console.log('componentDidMount took', (endTime - startTime) / 1000, 'sec'); } 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(); } 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 === new HDSegwitP2SHWallet().type), address: this.state.address, memo: this.state.memo, fromWallet: this.state.fromWallet, tx: tx, satoshiPerByte: actualSatoshiPerByte.toFixed(2), }), ); }); } renderFeeSelectionModal = () => { return ( this.setState({ isFeeSelectionModalVisible: false })} > this.textInput.focus()}> { this.textInput = ref; }} value={this.state.fee.toString()} onChangeText={value => { let newValue = value.replace(/\D/g, ''); if (newValue.length === 0) { newValue = 1; } this.setState({ fee: newValue, feeSliderValue: 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} /> )} ); }; render() { if (!this.state.fromWallet.getAddress) { return ( System error: Source wallet not found (this should never happen) ); } return ( this.setState({ amount: text.replace(',', '.') })} placeholder="0" maxLength={10} editable={!this.state.isLoading} value={this.state.amount + ''} placeholderTextColor="#0f5cc0" style={{ color: '#0f5cc0', fontSize: 36, fontWeight: '600', }} /> {' ' + BitcoinUnit.BTC} {currency.satoshiToLocalCurrency(loc.formatBalanceWithoutSuffix(this.state.amount || 0, BitcoinUnit.SATOSHIS))} { 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), memo: response.memo, fee: response.fee, bip70TransactionExpiration: response.expires, isLoading: false, }); }); }, ); } else { this.setState({ address: text.replace(' ', ''), isLoading: false, bip70TransactionExpiration: null }); } }} placeholder={loc.send.details.address} numberOfLines={1} value={this.state.address} style={{ flex: 1, marginHorizontal: 8, minHeight: 33 }} editable={!this.state.isLoading} /> this.props.navigation.navigate('ScanQrAddress')} style={{ width: 75, height: 36, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#bebebe', borderRadius: 4, paddingVertical: 4, paddingHorizontal: 8, marginHorizontal: 4, }} > {loc.send.details.scan} 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()} ); } } 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.function, navigate: 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, }), }), }), };