ADD: new choose fee workflow

This commit is contained in:
Ivan 2020-09-14 13:49:08 +03:00 committed by GitHub
parent 9a01256cef
commit 46f1ced76c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 444 additions and 356 deletions

View file

@ -36,15 +36,15 @@ import ToolTip from 'react-native-tooltip';
import { BlurView } from '@react-native-community/blur';
import ImagePicker from 'react-native-image-picker';
import showPopupMenu from 'react-native-popup-menu-android';
import NetworkTransactionFees, { NetworkTransactionFeeType } from './models/networkTransactionFees';
import NetworkTransactionFees, { NetworkTransactionFee, NetworkTransactionFeeType } from './models/networkTransactionFees';
import Biometric from './class/biometrics';
import { getSystemName } from 'react-native-device-info';
import { encodeUR } from 'bc-ur/dist';
import QRCode from 'react-native-qrcode-svg';
import AsyncStorage from '@react-native-community/async-storage';
import { useTheme } from '@react-navigation/native';
import { BlueCurrentTheme } from './components/themes';
import loc, { formatBalance, formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros, transactionTimeToReadable } from './loc';
import AsyncStorage from '@react-native-community/async-storage';
import Lnurl from './class/lnurl';
import ScanQRCode from './screen/send/ScanQRCode';
/** @type {AppStorage} */
@ -2288,15 +2288,24 @@ export class BlueReplaceFeeSuggestions extends Component {
};
static defaultProps = {
onFeeSelected: undefined,
transactionMinimum: 1,
};
state = { networkFees: undefined, selectedFeeType: NetworkTransactionFeeType.FAST, customFeeValue: 0 };
state = {
selectedFeeType: NetworkTransactionFeeType.FAST,
customFeeValue: '1',
};
async componentDidMount() {
try {
const cachedNetworkTransactionFees = JSON.parse(await AsyncStorage.getItem(NetworkTransactionFee.StorageKey));
if (cachedNetworkTransactionFees && 'fastestFee' in cachedNetworkTransactionFees) {
this.setState({ networkFees: cachedNetworkTransactionFees });
}
} catch (_) {}
const networkFees = await NetworkTransactionFees.recommendedFees();
this.setState({ networkFees }, () => this.onFeeSelected(NetworkTransactionFeeType.FAST));
this.setState({ networkFees });
}
onFeeSelected = selectedFeeType => {
@ -2311,103 +2320,117 @@ export class BlueReplaceFeeSuggestions extends Component {
} else if (selectedFeeType === NetworkTransactionFeeType.SLOW) {
this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.slowFee));
} else if (selectedFeeType === NetworkTransactionFeeType.CUSTOM) {
this.props.onFeeSelected(this.state.customFeeValue);
this.props.onFeeSelected(Number(this.state.customFeeValue));
}
};
onCustomFeeTextChange = customFee => {
this.setState({ customFeeValue: Number(customFee), selectedFeeType: NetworkTransactionFeeType.CUSTOM }, () => {
const customFeeValue = customFee.replace(/[^0-9]/g, '');
this.setState({ customFeeValue, selectedFeeType: NetworkTransactionFeeType.CUSTOM }, () => {
this.onFeeSelected(NetworkTransactionFeeType.CUSTOM);
});
};
render() {
const { networkFees, selectedFeeType } = this.state;
return (
<View>
{this.state.networkFees && (
<>
<BlueText>Suggestions</BlueText>
<BlueListItem
onPress={() => this.onFeeSelected(NetworkTransactionFeeType.FAST)}
containerStyle={{ paddingHorizontal: 0, marginHorizontal: 0, backgroundColor: BlueCurrentTheme.colors.transparent }}
bottomDivider={false}
title="Fast"
rightTitle={`${this.state.networkFees.fastestFee} sat/b`}
rightTitleStyle={{ fontSize: 13, color: BlueCurrentTheme.colors.alternativeTextColor }}
{...(this.state.selectedFeeType === NetworkTransactionFeeType.FAST
? { rightIcon: <Icon name="check" type="octaicon" color={BlueCurrentTheme.colors.successCheck} /> }
: { hideChevron: true })}
/>
<BlueListItem
onPress={() => this.onFeeSelected(NetworkTransactionFeeType.MEDIUM)}
containerStyle={{ paddingHorizontal: 0, marginHorizontal: 0, backgroundColor: BlueCurrentTheme.colors.transparent }}
bottomDivider={false}
title="Medium"
rightTitle={`${this.state.networkFees.mediumFee} sat/b`}
rightTitleStyle={{ fontSize: 13, color: BlueCurrentTheme.colors.alternativeTextColor }}
{...(this.state.selectedFeeType === NetworkTransactionFeeType.MEDIUM
? { rightIcon: <Icon name="check" type="octaicon" color={BlueCurrentTheme.colors.successCheck} /> }
: { hideChevron: true })}
/>
<BlueListItem
onPress={() => this.onFeeSelected(NetworkTransactionFeeType.SLOW)}
containerStyle={{ paddingHorizontal: 0, marginHorizontal: 0, backgroundColor: BlueCurrentTheme.colors.transparent }}
bottomDivider={false}
title="Slow"
rightTitle={`${this.state.networkFees.slowFee} sat/b`}
rightTitleStyle={{ fontSize: 13, color: BlueCurrentTheme.colors.alternativeTextColor }}
{...(this.state.selectedFeeType === NetworkTransactionFeeType.SLOW
? { rightIcon: <Icon name="check" type="octaicon" color={BlueCurrentTheme.colors.successCheck} /> }
: { hideChevron: true })}
/>
</>
)}
<TouchableOpacity onPress={() => this.customTextInput.focus()}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: 0, alignItems: 'center' }}>
<Text style={{ color: BlueCurrentTheme.colors.foregroundColor, fontSize: 16, fontWeight: '500' }}>Custom</Text>
<View
style={{
flexDirection: 'row',
minHeight: 44,
height: 44,
minWidth: 48,
alignItems: 'center',
justifyContent: 'flex-end',
marginVertical: 8,
}}
{networkFees &&
[
{
label: loc.send.fee_fast,
time: loc.send.fee_10m,
type: NetworkTransactionFeeType.FAST,
rate: networkFees.fastestFee,
active: selectedFeeType === NetworkTransactionFeeType.FAST,
},
{
label: loc.send.fee_medium,
time: loc.send.fee_3h,
type: NetworkTransactionFeeType.MEDIUM,
rate: networkFees.mediumFee,
active: selectedFeeType === NetworkTransactionFeeType.MEDIUM,
},
{
label: loc.send.fee_slow,
time: loc.send.fee_1d,
type: NetworkTransactionFeeType.SLOW,
rate: networkFees.slowFee,
active: selectedFeeType === NetworkTransactionFeeType.SLOW,
},
].map(({ label, type, time, rate, active }, index) => (
<TouchableOpacity
key={label}
onPress={() => this.onFeeSelected(type)}
style={[
{ paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10 },
active && { borderRadius: 8, backgroundColor: BlueCurrentTheme.colors.incomingBackgroundColor },
]}
>
<TextInput
onChangeText={this.onCustomFeeTextChange}
keyboardType="numeric"
value={this.state.customFeeValue}
ref={ref => (this.customTextInput = ref)}
maxLength={9}
style={{
borderColor: BlueCurrentTheme.colors.formBorder,
borderBottomColor: BlueCurrentTheme.colors.formBorder,
borderWidth: 1.0,
borderBottomWidth: 0.5,
borderRadius: 4,
minHeight: 33,
maxWidth: 100,
minWidth: 44,
color: '#81868e',
backgroundColor: BlueCurrentTheme.colors.inputBackgroundColor,
textAlign: 'right',
}}
onFocus={() => this.onCustomFeeTextChange(this.state.customFeeValue)}
defaultValue={`${this.props.transactionMinimum}`}
placeholder="Custom sat/b"
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
/>
<Text style={{ color: BlueCurrentTheme.colors.alternativeTextColor, marginHorizontal: 8 }}>sat/b</Text>
{this.state.selectedFeeType === NetworkTransactionFeeType.CUSTOM && <Icon name="check" type="octaicon" color="#0070FF" />}
</View>
<BlueDismissKeyboardInputAccessory />
<View style={{ justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 22, color: BlueCurrentTheme.colors.successColor, fontWeight: '600' }}>{label}</Text>
<View
style={{
backgroundColor: BlueCurrentTheme.colors.successColor,
borderRadius: 5,
paddingHorizontal: 6,
paddingVertical: 3,
}}
>
<Text style={{ color: BlueCurrentTheme.colors.background }}>~{time}</Text>
</View>
</View>
<View style={{ justifyContent: 'flex-end', flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ color: BlueCurrentTheme.colors.successColor }}>{rate} sat/byte</Text>
</View>
</TouchableOpacity>
))}
<TouchableOpacity
onPress={() => this.customTextInput.focus()}
style={[
{ paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10 },
selectedFeeType === NetworkTransactionFeeType.CUSTOM && {
borderRadius: 8,
backgroundColor: BlueCurrentTheme.colors.incomingBackgroundColor,
},
]}
>
<View style={{ justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 22, color: BlueCurrentTheme.colors.successColor, fontWeight: '600' }}>{loc.send.fee_custom}</Text>
</View>
<View style={{ justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', marginTop: 5 }}>
<TextInput
onChangeText={this.onCustomFeeTextChange}
keyboardType="numeric"
value={this.state.customFeeValue}
ref={ref => (this.customTextInput = ref)}
maxLength={9}
style={{
backgroundColor: BlueCurrentTheme.colors.inputBackgroundColor,
borderBottomColor: BlueCurrentTheme.colors.formBorder,
borderBottomWidth: 0.5,
borderColor: BlueCurrentTheme.colors.formBorder,
borderRadius: 4,
borderWidth: 1.0,
color: '#81868e',
flex: 1,
marginRight: 10,
minHeight: 33,
paddingRight: 5,
textAlign: 'right',
}}
onFocus={() => this.onCustomFeeTextChange(this.state.customFeeValue)}
defaultValue={`${this.props.transactionMinimum}`}
placeholder={loc.send.fee_satbyte}
placeholderTextColor="#81868e"
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
/>
<Text style={{ color: BlueCurrentTheme.colors.successColor }}>sat/byte</Text>
</View>
</TouchableOpacity>
<BlueText style={{ color: BlueCurrentTheme.colors.alternativeTextColor }}>
The total fee rate (satoshi per byte) you want to pay should be higher than {this.props.transactionMinimum} sat/byte
{loc.formatString(loc.send.fee_replace_min, { min: this.props.transactionMinimum })}
</BlueText>
</View>
);

View file

@ -6,8 +6,6 @@ import { AbstractHDWallet } from './abstract-hd-wallet';
const bitcoin = require('bitcoinjs-lib');
const BlueElectrum = require('../../blue_modules/BlueElectrum');
const HDNode = require('bip32');
const coinSelectAccumulative = require('coinselect/accumulative');
const coinSelectSplit = require('coinselect/split');
const reverse = require('buffer-reverse');
/**
@ -806,24 +804,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
* @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}}
*/
createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) {
if (!changeAddress) throw new Error('No change address provided');
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress);
sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence;
let algo = coinSelectAccumulative;
if (targets.length === 1 && targets[0] && !targets[0].value) {
// we want to send MAX
algo = coinSelectSplit;
}
const { inputs, outputs, fee } = algo(utxos, targets, feeRate);
// .inputs and .outputs will be undefined if no solution was found
if (!inputs || !outputs) {
throw new Error('Not enough balance. Try sending smaller amount');
}
let psbt = new bitcoin.Psbt();
let c = 0;
const keypairs = {};
const values = {};

View file

@ -253,20 +253,8 @@ export class LegacyWallet extends AbstractWallet {
return broadcast.length === 64; // this means return string is txid (precise length), so it was broadcasted ok
}
/**
*
* @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String, txhex: String, }>} List of spendable utxos
* @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate)
* @param feeRate {Number} satoshi per byte
* @param changeAddress {String} Excessive coins will go back to that address
* @param sequence {Number} Used in RBF
* @param skipSigning {boolean} Whether we should skip signing, use returned `psbt` in that case
* @param masterFingerprint {number} Decimal number of wallet's master fingerprint
* @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}}
*/
createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) {
coinselect(utxos, targets, feeRate, changeAddress) {
if (!changeAddress) throw new Error('No change address provided');
sequence = sequence || 0xffffffff; // disable RBF by default
let algo = coinSelectAccumulative;
if (targets.length === 1 && targets[0] && !targets[0].value) {
@ -281,8 +269,24 @@ export class LegacyWallet extends AbstractWallet {
throw new Error('Not enough balance. Try sending smaller amount');
}
const psbt = new bitcoin.Psbt();
return { inputs, outputs, fee };
}
/**
*
* @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String, txhex: String, }>} List of spendable utxos
* @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate)
* @param feeRate {Number} satoshi per byte
* @param changeAddress {String} Excessive coins will go back to that address
* @param sequence {Number} Used in RBF
* @param skipSigning {boolean} Whether we should skip signing, use returned `psbt` in that case
* @param masterFingerprint {number} Decimal number of wallet's master fingerprint
* @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}}
*/
createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) {
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress);
sequence = sequence || 0xffffffff; // disable RBF by default
const psbt = new bitcoin.Psbt();
let c = 0;
const values = {};
let keyPair;

View file

@ -1,7 +1,5 @@
import { LegacyWallet } from './legacy-wallet';
const bitcoin = require('bitcoinjs-lib');
const coinSelectAccumulative = require('coinselect/accumulative');
const coinSelectSplit = require('coinselect/split');
export class SegwitBech32Wallet extends LegacyWallet {
static type = 'segwitBech32';
@ -67,24 +65,9 @@ export class SegwitBech32Wallet extends LegacyWallet {
* @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}}
*/
createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) {
if (!changeAddress) throw new Error('No change address provided');
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress);
sequence = sequence || 0xffffffff; // disable RBF by default
let algo = coinSelectAccumulative;
if (targets.length === 1 && targets[0] && !targets[0].value) {
// we want to send MAX
algo = coinSelectSplit;
}
const { inputs, outputs, fee } = algo(utxos, targets, feeRate);
// .inputs and .outputs will be undefined if no solution was found
if (!inputs || !outputs) {
throw new Error('Not enough balance. Try sending smaller amount');
}
const psbt = new bitcoin.Psbt();
let c = 0;
const values = {};
let keyPair;

View file

@ -1,7 +1,5 @@
import { LegacyWallet } from './legacy-wallet';
const bitcoin = require('bitcoinjs-lib');
const coinSelectAccumulative = require('coinselect/accumulative');
const coinSelectSplit = require('coinselect/split');
/**
* Creates Segwit P2SH Bitcoin address
@ -78,24 +76,9 @@ export class SegwitP2SHWallet extends LegacyWallet {
* @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}}
*/
createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) {
if (!changeAddress) throw new Error('No change address provided');
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress);
sequence = sequence || 0xffffffff; // disable RBF by default
let algo = coinSelectAccumulative;
if (targets.length === 1 && targets[0] && !targets[0].value) {
// we want to send MAX
algo = coinSelectSplit;
}
const { inputs, outputs, fee } = algo(utxos, targets, feeRate);
// .inputs and .outputs will be undefined if no solution was found
if (!inputs || !outputs) {
throw new Error('Not enough balance. Try sending smaller amount');
}
const psbt = new bitcoin.Psbt();
let c = 0;
const values = {};
let keyPair;

View file

@ -181,6 +181,15 @@
"dynamic_prev": "Previous",
"dynamic_start": "Start",
"dynamic_stop": "Stop",
"fee_10m": "10m",
"fee_1d": "1d",
"fee_3h": "3h",
"fee_custom": "Custom",
"fee_fast": "Fast",
"fee_medium": "Medium",
"fee_replace_min": "The total fee rate (satoshi per byte) you want to pay should be higher than {min} sat/byte",
"fee_satbyte": "in sat/byte",
"fee_slow": "Slow",
"header": "Send",
"input_clear": "Clear",
"input_done": "Done",
@ -243,6 +252,7 @@
"general_adv_mode_e": "When enabled, you will see advanced options such as different wallet types, the ability to specify the LNDHub instance you wish to connect to and custom entropy during wallet creation.",
"general_continuity": "Continuity",
"general_continuity_e": "When enabled, you will be able to view selected wallets, and transactions, using your other Apple iCloud connected devices.",
"groundcontrol_explanation": "GroundControl is a free opensource push notifications server for bitcoin wallets. You can install your own GroundControl server and put its URL here to not rely on BlueWallet's infrastructure. Leave blank to use default",
"header": "settings",
"language": "Language",
"language_restart": "When selecting a new language, restarting BlueWallet may be required for the change to take effect.",
@ -253,17 +263,16 @@
"network": "Network",
"network_broadcast": "Broadcast transaction",
"network_electrum": "Electrum server",
"not_a_valid_uri": "Not a valid URI",
"notifications": "Notifications",
"password": "Password",
"password_explain": "Create the password you will use to decrypt the storage",
"passwords_do_not_match": "Passwords do not match",
"plausible_deniability": "Plausible deniability",
"retype_password": "Re-type password",
"notifications": "Notifications",
"save": "Save",
"saved": "Saved",
"not_a_valid_uri": "Not a valid URI",
"push_notifications": "Push notifications",
"groundcontrol_explanation": "GroundControl is a free opensource push notifications server for bitcoin wallets. You can install your own GroundControl server and put its URL here to not rely on BlueWallet's infrastructure. Leave blank to use default"
"retype_password": "Re-type password",
"save": "Save",
"saved": "Saved"
},
"transactions": {
"cancel_explain": "We will replace this transaction with the one that pays you and has higher fees. This effectively cancels transaction. This is called RBF - Replace By Fee.",
@ -286,11 +295,11 @@
"enable_hw": "This wallet is not being used in conjunction with a hardwarde wallet. Would you like to enable hardware wallet use?",
"list_conf": "conf",
"list_title": "transactions",
"transactions_count": "transactions count",
"rbf_explain": "We will replace this transaction with the one with a higher fee, so it should be mined faster. This is called RBF - Replace By Fee.",
"rbf_title": "Bump fee (RBF)",
"status_bump": "Bump Fee",
"status_cancel": "Cancel Transaction"
"status_cancel": "Cancel Transaction",
"transactions_count": "transactions count"
},
"wallets": {
"add_bitcoin": "Bitcoin",

View file

@ -30,10 +30,11 @@ import {
BlueListItem,
BlueText,
} from '../../BlueComponents';
import Slider from '@react-native-community/slider';
import Modal from 'react-native-modal';
import RNFS from 'react-native-fs';
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';
@ -43,10 +44,9 @@ import DocumentPicker from 'react-native-document-picker';
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
import loc from '../../loc';
import { BlueCurrentTheme } from '../../components/themes';
const bitcoin = require('bitcoinjs-lib');
const currency = require('../../blue_modules/currency');
const BigNumber = require('bignumber.js');
const BlueApp: AppStorage = require('../../BlueApp');
const prompt = require('../../blue_modules/prompt');
const btcAddressRx = /^[a-zA-Z0-9]{26,35}$/;
@ -68,14 +68,11 @@ const styles = StyleSheet.create({
modalContent: {
backgroundColor: BlueCurrentTheme.colors.modal,
padding: 22,
justifyContent: 'center',
alignItems: 'center',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderTopColor: BlueCurrentTheme.colors.borderTopColor,
borderWidth: BlueCurrentTheme.colors.borderWidth,
minHeight: 200,
height: 200,
},
advancedTransactionOptionsModalContent: {
backgroundColor: BlueCurrentTheme.colors.modal,
@ -90,51 +87,46 @@ const styles = StyleSheet.create({
justifyContent: 'flex-end',
margin: 0,
},
feeSliderInput: {
backgroundColor: BlueCurrentTheme.colors.feeLabel,
minWidth: 127,
height: 60,
feeModalItem: {
paddingHorizontal: 16,
paddingVertical: 8,
marginBottom: 10,
},
feeModalItemActive: {
borderRadius: 8,
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 8,
backgroundColor: BlueCurrentTheme.colors.feeLabel,
},
feeSliderText: {
fontWeight: '600',
color: BlueCurrentTheme.colors.feeValue,
marginBottom: 0,
marginRight: 4,
textAlign: 'right',
fontSize: 36,
},
feeSliderUnit: {
fontWeight: '600',
color: BlueCurrentTheme.colors.feeValue,
paddingRight: 4,
textAlign: 'left',
fontSize: 16,
alignSelf: 'flex-end',
marginBottom: 14,
},
sliderContainer: {
flex: 1,
marginTop: 32,
minWidth: 240,
width: 240,
},
slider: {
flex: 1,
},
sliderLabels: {
flex: 1,
flexDirection: 'row',
feeModalRow: {
justifyContent: 'space-between',
marginTop: 14,
flexDirection: 'row',
alignItems: 'center',
},
sliderLabel: {
fontWeight: '500',
fontSize: 13,
color: '#37c0a1',
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: {
marginHorizontal: 56,
@ -212,14 +204,6 @@ const styles = StyleSheet.create({
},
feeValue: {
color: BlueCurrentTheme.colors.feeValue,
marginBottom: 0,
marginRight: 4,
textAlign: 'right',
},
feeUnit: {
color: BlueCurrentTheme.colors.feeValue,
paddingRight: 4,
textAlign: 'left',
},
});
@ -257,8 +241,14 @@ export default class SendDetails extends Component {
units: [],
memo: '',
networkTransactionFees: new NetworkTransactionFee(1, 1, 1),
fee: 1,
feeSliderValue: 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 - 320,
@ -361,39 +351,40 @@ export default class SendDetails extends Component {
try {
const cachedNetworkTransactionFees = JSON.parse(await AsyncStorage.getItem(NetworkTransactionFee.StorageKey));
if (cachedNetworkTransactionFees && 'mediumFee' in cachedNetworkTransactionFees) {
if (cachedNetworkTransactionFees && 'fastestFee' in cachedNetworkTransactionFees) {
this.setState({
fee: cachedNetworkTransactionFees.fastestFee,
fee: cachedNetworkTransactionFees.fastestFee.toString(),
networkTransactionFees: cachedNetworkTransactionFees,
feeSliderValue: cachedNetworkTransactionFees.fastestFee,
});
}
} catch (_) {}
await this.reCalcTx();
try {
const recommendedFees = await NetworkTransactionFees.recommendedFees();
if (recommendedFees && 'fastestFee' in recommendedFees) {
await AsyncStorage.setItem(NetworkTransactionFee.StorageKey, JSON.stringify(recommendedFees));
this.setState({
fee: recommendedFees.fastestFee,
fee: recommendedFees.fastestFee.toString(),
networkTransactionFees: recommendedFees,
feeSliderValue: recommendedFees.fastestFee,
});
if (this.props.route.params.uri) {
try {
const { address, amount, memo } = this.decodeBitcoinUri(this.props.route.params.uri);
this.setState({ address, amount, memo, isLoading: false });
} catch (error) {
console.log(error);
this.setState({ isLoading: false });
alert(loc.send.details_error_decode);
}
}
} else {
this.setState({ isLoading: false });
}
} catch (_e) {}
} catch (_) {}
if (this.props.route.params.uri) {
try {
const { address, amount, memo } = this.decodeBitcoinUri(this.props.route.params.uri);
this.setState({ address, amount, memo });
} catch (error) {
console.log(error);
alert(loc.send.details_error_decode);
}
}
await this.state.fromWallet.fetchUtxo();
this.setState({ isLoading: false });
await this.reCalcTx();
}
componentWillUnmount() {
@ -433,13 +424,13 @@ export default class SendDetails extends Component {
async createTransaction() {
Keyboard.dismiss();
this.setState({ isLoading: true });
let error = false;
const requestedSatPerByte = this.state.fee.toString().replace(/\D/g, '');
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 (!this.state.fee || !requestedSatPerByte || parseFloat(requestedSatPerByte) < 1) {
} else if (!requestedSatPerByte || parseFloat(requestedSatPerByte) < 1) {
error = loc.send.details_fee_field_is_not_valid;
console.log('validation error');
} else if (!transaction.address) {
@ -483,10 +474,6 @@ export default class SendDetails extends Component {
}
}
if (error) {
return;
}
try {
await this.createPsbtTransaction();
} catch (Err) {
@ -497,12 +484,88 @@ export default class SendDetails extends Component {
}
}
/**
* Recalculating fee options by creating skeleton of future tx.
*/
reCalcTx = async (all = false) => {
const wallet = this.state.fromWallet;
const fees = this.state.networkTransactionFees;
const changeAddress = await wallet.getChangeAddressAsync();
const requestedSatPerByte = Number(this.state.fee);
const feePrecalc = { ...this.state.feePrecalc };
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(
wallet.getUtxo(),
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;
await wallet.fetchUtxo();
const changeAddress = await wallet.getChangeAddressAsync();
const requestedSatPerByte = +this.state.fee.toString().replace(/\D/g, '');
const requestedSatPerByte = Number(this.state.fee);
console.log({ requestedSatPerByte, utxo: wallet.getUtxo() });
let targets = [];
@ -617,64 +680,91 @@ export default class SendDetails extends Component {
};
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 (
<Modal
deviceHeight={Dimensions.get('window').height}
deviceWidth={this.state.width + this.state.width / 2}
isVisible={this.state.isFeeSelectionModalVisible}
style={styles.bottomModal}
onBackdropPress={() => {
if (this.state.fee < 1 || this.state.feeSliderValue < 1) {
this.setState({ fee: Number(1), feeSliderValue: Number(1) });
}
Keyboard.dismiss();
this.setState({ isFeeSelectionModalVisible: false });
}}
onBackdropPress={() => this.setState({ isFeeSelectionModalVisible: false })}
>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'position' : null}>
<View style={styles.modalContent}>
<TouchableOpacity style={styles.feeSliderInput} onPress={() => this.textInput.focus()}>
<TextInput
keyboardType="numeric"
ref={ref => {
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 => {
const newValue = value.replace(/\D/g, '');
this.setState({ fee: newValue, feeSliderValue: Number(newValue) });
}}
maxLength={9}
editable={!this.state.isLoading}
placeholderTextColor="#37c0a1"
placeholder={this.state.networkTransactionFees.mediumFee.toString()}
style={styles.feeSliderText}
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
/>
<Text style={styles.feeSliderUnit}>sat/b</Text>
</TouchableOpacity>
{this.state.networkTransactionFees.fastestFee > 1 && (
<View style={styles.sliderContainer}>
<Slider
onValueChange={value => this.setState({ feeSliderValue: value.toFixed(0), fee: value.toFixed(0) })}
minimumValue={1}
maximumValue={Number(this.state.networkTransactionFees.fastestFee)}
value={Number(this.state.feeSliderValue)}
maximumTrackTintColor="#d8d8d8"
minimumTrackTintColor="#37c0a1"
style={styles.slider}
/>
<View style={styles.sliderLabels}>
<Text style={styles.sliderLabel}>slow</Text>
<Text style={styles.sliderLabel}>fast</Text>
{options.map(({ label, time, fee, rate, active }, index) => (
<TouchableOpacity
key={label}
onPress={() =>
this.setState(({ feePrecalc }) => {
feePrecalc.current = fee;
return { isFeeSelectionModalVisible: false, fee: rate.toString(), feePrecalc };
})
}
style={[styles.feeModalItem, active && styles.feeModalItemActive]}
>
<View style={styles.feeModalRow}>
<Text style={styles.feeModalLabel}>{label}</Text>
<View style={styles.feeModalTime}>
<Text style={styles.feeModalTimeText}>~{time}</Text>
</View>
</View>
</View>
)}
<View style={styles.feeModalRow}>
<Text style={styles.feeModalValue}>{fee && this.formatFee(fee)}</Text>
<Text style={styles.feeModalValue}>{rate} sat/byte</Text>
</View>
</TouchableOpacity>
))}
<TouchableOpacity
style={styles.feeModalCustom}
onPress={async () => {
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 });
return;
}
}}
>
<Text style={styles.feeModalCustomText}>{loc.send.fee_custom}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</Modal>
@ -864,7 +954,7 @@ export default class SendDetails extends Component {
handlePageChange = e => {
Keyboard.dismiss();
var offset = e.nativeEvent.contentOffset;
const offset = e.nativeEvent.contentOffset;
if (offset) {
const page = Math.round(offset.x / this.state.width);
if (this.state.recipientsScrollIndex !== page) {
@ -875,7 +965,7 @@ export default class SendDetails extends Component {
scrollViewCurrentIndex = () => {
Keyboard.dismiss();
var offset = this.scrollView.contentOffset;
const offset = this.scrollView.contentOffset;
if (offset) {
const page = Math.round(offset.x / this.state.width);
return page;
@ -932,7 +1022,7 @@ export default class SendDetails extends Component {
}
const addresses = this.state.addresses;
addresses[index] = item;
this.setState({ addresses });
this.setState({ addresses }, this.reCalcTx);
}}
unit={this.state.units[index] || this.state.amountUnit}
inputAccessoryViewID={this.state.fromWallet.allowSendMax() ? BlueUseAllFundsButton.InputAccessoryViewID : null}
@ -950,6 +1040,7 @@ export default class SendDetails extends Component {
memo: memo || this.state.memo,
isLoading: false,
});
this.reCalcTx();
}}
onBarScanned={this.processAddressData}
address={item.address}
@ -994,6 +1085,17 @@ export default class SendDetails extends Component {
);
};
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 });
};
@ -1006,6 +1108,7 @@ export default class SendDetails extends Component {
</View>
);
}
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.root} onLayout={this.onLayout}>
@ -1040,14 +1143,15 @@ export default class SendDetails extends Component {
/>
</View>
<TouchableOpacity
onPress={() => this.setState({ isFeeSelectionModalVisible: true })}
onPress={() => this.setState({ isFeeSelectionModalVisible: true }, () => this.reCalcTx(true))}
disabled={this.state.isLoading}
style={styles.fee}
>
<Text style={styles.feeLabel}>{loc.send.create_fee}</Text>
<View style={styles.feeRow}>
<Text style={styles.feeValue}>{this.state.fee}</Text>
<Text style={styles.feeUnit}>sat/b</Text>
<Text style={styles.feeValue}>
{this.state.feePrecalc.current ? this.formatFee(this.state.feePrecalc.current) : this.state.fee + ' sat/byte'}
</Text>
</View>
</TouchableOpacity>
{this.renderCreateButton()}

View file

@ -16,6 +16,7 @@ import {
BlueNavigationStyle,
BlueBigCheckmark,
} from '../../BlueComponents';
import { BlueCurrentTheme } from '../../components/themes';
import { HDSegwitBech32Transaction, HDSegwitBech32Wallet } from '../../class';
import loc from '../../loc';
const EV = require('../../blue_modules/events');
@ -38,7 +39,7 @@ const styles = StyleSheet.create({
flex: 1,
},
hex: {
color: '#0c2550',
color: BlueCurrentTheme.colors.buttonAlternativeTextColor,
fontWeight: '500',
},
hexInput: {
@ -164,40 +165,21 @@ export default class CPFP extends Component {
}
}
render() {
if (this.state.isLoading) {
return (
<View style={styles.root}>
<ActivityIndicator />
</View>
);
}
if (this.state.stage === 3) {
return this.renderStage3();
}
if (this.state.stage === 2) {
return this.renderStage2();
}
if (this.state.nonReplaceable) {
return (
<SafeBlueArea style={styles.root}>
<BlueSpacing20 />
<BlueSpacing20 />
<BlueSpacing20 />
<BlueSpacing20 />
<BlueSpacing20 />
<BlueText h4>{loc.transactions.cpfp_no_bump}</BlueText>
</SafeBlueArea>
);
}
renderStage1(text) {
return (
<SafeBlueArea style={styles.explain}>
<ScrollView>{this.renderStage1(loc.transactions.cpfp_exp)}</ScrollView>
<SafeBlueArea style={styles.root}>
<BlueSpacing />
<BlueCard style={styles.center}>
<BlueText>{text}</BlueText>
<BlueSpacing20 />
<BlueReplaceFeeSuggestions onFeeSelected={fee => this.setState({ newFeeRate: fee })} transactionMinimum={this.state.feeRate} />
<BlueSpacing />
<BlueButton
disabled={this.state.newFeeRate <= this.state.feeRate}
onPress={() => this.createTransaction()}
title={loc.transactions.cpfp_create}
/>
</BlueCard>
</SafeBlueArea>
);
}
@ -235,24 +217,40 @@ export default class CPFP extends Component {
);
}
renderStage1(text) {
return (
<SafeBlueArea style={styles.root}>
<BlueSpacing />
<BlueCard style={styles.center}>
<BlueText>{text}</BlueText>
render() {
if (this.state.isLoading) {
return (
<View style={styles.root}>
<ActivityIndicator />
</View>
);
}
if (this.state.stage === 3) {
return this.renderStage3();
}
if (this.state.stage === 2) {
return this.renderStage2();
}
if (this.state.nonReplaceable) {
return (
<SafeBlueArea style={styles.root}>
<BlueSpacing20 />
<BlueReplaceFeeSuggestions
onFeeSelected={fee => this.setState({ newFeeRate: fee })}
transactionMinimum={this.state.feeRate + 1}
/>
<BlueSpacing />
<BlueButton
disabled={this.state.newFeeRate <= this.state.feeRate}
onPress={() => this.createTransaction()}
title={loc.transactions.cpfp_create}
/>
</BlueCard>
<BlueSpacing20 />
<BlueSpacing20 />
<BlueSpacing20 />
<BlueSpacing20 />
<BlueText h4>{loc.transactions.cpfp_no_bump}</BlueText>
</SafeBlueArea>
);
}
return (
<SafeBlueArea style={styles.explain}>
<ScrollView>{this.renderStage1(loc.transactions.cpfp_exp)}</ScrollView>
</SafeBlueArea>
);
}

View file

@ -1,8 +1,8 @@
/* global alert */
import React from 'react';
import PropTypes from 'prop-types';
import { ActivityIndicator, View, ScrollView, StyleSheet } from 'react-native';
import { BlueSpacing20, SafeBlueArea, BlueText, BlueNavigationStyle } from '../../BlueComponents';
import PropTypes from 'prop-types';
import { HDSegwitBech32Transaction, HDSegwitBech32Wallet } from '../../class';
import CPFP from './CPFP';
import loc from '../../loc';

View file

@ -213,7 +213,6 @@ const WalletTransactions = () => {
backgroundColor: colors.background,
},
});
const interval = setInterval(() => setTimeElapsed(prev => ({ timeElapsed: prev.timeElapsed + 1 })), 60000);
/**
* Simple wrapper for `wallet.getTransactions()`, where `wallet` is current wallet.
@ -237,6 +236,7 @@ const WalletTransactions = () => {
useEffect(() => {
EV(EV.enum.REMOTE_TRANSACTIONS_COUNT_CHANGED, refreshTransactionsFunction, true);
HandoffSettings.isHandoffUseEnabled().then(setIsHandOffUseEnabled);
const interval = setInterval(() => setTimeElapsed(prev => prev + 1), 60000);
return () => {
clearInterval(interval);
navigate('DrawerRoot', { selectedWallet: '' });