diff --git a/app/components/Base/hooks/useModalHandler.js b/app/components/Base/hooks/useModalHandler.js index c5371a91e46..7dc0df8f22c 100644 --- a/app/components/Base/hooks/useModalHandler.js +++ b/app/components/Base/hooks/useModalHandler.js @@ -1,10 +1,24 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; + +/** + * @typedef {Boolean} isVisible boolean value that represent wether the modal is visible or not + * @typedef {Function} toggleModal function that toggles the isVisible boolean value + * @typedef {Function} showModal function that sets isVisible boolean to true + * @typedef {Function} hideModal function that sets isVisible boolean to false + * @typedef {[isVisible, toggleModal, showModal, hideModal]} Handlers + */ + +/** + * Hook to handle modal visibility state + * @param {Boolean} initialState Initial state of the modal, if true modal will be visible + * @return {Handlers} Handlers `[isVisible, toggleModal, showModal, hideModal]` + */ function useModalHandler(initialState = false) { const [isVisible, setVisible] = useState(initialState); - const showModal = () => setVisible(true); - const hideModal = () => setVisible(true); - const toggleModal = () => setVisible(!isVisible); + const showModal = useCallback(() => setVisible(true), []); + const hideModal = useCallback(() => setVisible(false), []); + const toggleModal = useCallback(() => setVisible(visible => !visible), []); return [isVisible, toggleModal, showModal, hideModal]; } diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index ed77936665a..59b249083ca 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -50,6 +50,7 @@ import PaymentMethodApplePay from '../../UI/FiatOrders/PaymentMethodApplePay'; import TransakWebView from '../../UI/FiatOrders/TransakWebView'; import ActivityView from '../../Views/ActivityView'; import SwapsAmountView from '../../UI/Swaps'; +import SwapsQuotesView from '../../UI/Swaps/QuotesView'; const styles = StyleSheet.create({ headerLogo: { @@ -275,7 +276,8 @@ export default createStackNavigator( }, Swaps: { screen: createStackNavigator({ - SwapsAmountView: { screen: SwapsAmountView } + SwapsAmountView: { screen: SwapsAmountView }, + SwapsQuotesView: { screen: SwapsQuotesView } }) }, SetPasswordFlow: { diff --git a/app/components/Nav/Main/__snapshots__/index.test.js.snap b/app/components/Nav/Main/__snapshots__/index.test.js.snap index 0f8f189a53c..860f00a668c 100644 --- a/app/components/Nav/Main/__snapshots__/index.test.js.snap +++ b/app/components/Nav/Main/__snapshots__/index.test.js.snap @@ -251,6 +251,7 @@ exports[`Main should render correctly 1`] = ` "Swaps": Object { "childRouters": Object { "SwapsAmountView": null, + "SwapsQuotesView": null, }, "getActionCreators": [Function], "getActionForPathAndParams": [Function], @@ -585,6 +586,7 @@ exports[`Main should render correctly 1`] = ` "Swaps": Object { "childRouters": Object { "SwapsAmountView": null, + "SwapsQuotesView": null, }, "getActionCreators": [Function], "getActionForPathAndParams": [Function], diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 0cb177e3acf..34e2d5abcc6 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -38,7 +38,8 @@ import { getMethodData, TOKEN_METHOD_TRANSFER, decodeTransferData, - APPROVE_FUNCTION_SIGNATURE + APPROVE_FUNCTION_SIGNATURE, + decodeApproveData } from '../../../util/transactions'; import { BN, isValidAddress } from 'ethereumjs-util'; import { isENS, safeToChecksumAddress } from '../../../util/address'; @@ -61,6 +62,7 @@ import ProtectYourWalletModal from '../../UI/ProtectYourWalletModal'; import MainNavigator from './MainNavigator'; import PaymentChannelApproval from '../../UI/PaymentChannelApproval'; import SkipAccountSecurityModal from '../../UI/SkipAccountSecurityModal'; +import { swapsUtils } from '@estebanmino/controllers'; const styles = StyleSheet.create({ flex: { @@ -194,14 +196,51 @@ const Main = props => { [props.navigation, props.transactions] ); + const autoSign = useCallback( + async transactionMeta => { + const { TransactionController } = Engine.context; + try { + TransactionController.hub.once(`${transactionMeta.id}:finished`, transactionMeta => { + if (transactionMeta.status === 'submitted') { + props.navigation.pop(); + NotificationManager.watchSubmittedTransaction({ + ...transactionMeta, + assetType: transactionMeta.transaction.assetType + }); + } else { + throw transactionMeta.error; + } + }); + await TransactionController.approveTransaction(transactionMeta.id); + } catch (error) { + Alert.alert(strings('transactions.transaction_error'), error && error.message, [ + { text: strings('navigation.ok') } + ]); + Logger.error(error, 'error while trying to send transaction (Main)'); + } + }, + [props.navigation] + ); + const onUnapprovedTransaction = useCallback( async transactionMeta => { if (transactionMeta.origin === TransactionTypes.MMM) return; const to = safeToChecksumAddress(transactionMeta.transaction.to); const networkId = Networks[props.providerType].networkId; + const { data } = transactionMeta.transaction; + + // if approval data includes metaswap contract + // if destination address is metaswap contract if ( + to === safeToChecksumAddress(swapsUtils.SWAPS_CONTRACT_ADDRESS) || + (data && + data.substr(0, 10) === APPROVE_FUNCTION_SIGNATURE && + decodeApproveData(data).spenderAddress === swapsUtils.SWAPS_CONTRACT_ADDRESS) + ) { + autoSign(transactionMeta); + } else if ( props.paymentChannelsEnabled && AppConstants.CONNEXT.SUPPORTED_NETWORKS.includes(props.providerType) && transactionMeta.transaction.data && @@ -279,7 +318,8 @@ const Main = props => { setEtherTransaction, setTransactionObject, toggleApproveModal, - toggleDappTransactionModal + toggleDappTransactionModal, + autoSign ] ); diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 91ef6d38038..4fbbf63e498 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -888,7 +888,7 @@ export function getTransakWebviewNavbar(navigation) { } export function getSwapsAmountNavbar(navigation) { - const title = navigation.getParam('title', 'Swaps'); + const title = navigation.getParam('title', 'Swap'); const rightAction = navigation.dismiss; return { @@ -902,3 +902,29 @@ export function getSwapsAmountNavbar(navigation) { ) }; } +export function getSwapsQuotesNavbar(navigation) { + const title = navigation.getParam('title', 'Swap'); + const rightAction = navigation.dismiss; + const leftAction = navigation.getParam('leftAction', strings('navigation.back')); + + return { + headerTitle: , + headerLeft: Device.isAndroid() ? ( + // eslint-disable-next-line react/jsx-no-bind + navigation.pop()} style={styles.backButton}> + + + ) : ( + // eslint-disable-next-line react/jsx-no-bind + navigation.pop()} style={styles.closeButton}> + {leftAction} + + ), + headerRight: ( + // eslint-disable-next-line react/jsx-no-bind + + {strings('navigation.cancel')} + + ) + }; +} diff --git a/app/components/UI/SliderButton/__snapshots__/index.test.js.snap b/app/components/UI/SliderButton/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000..1758291b99a --- /dev/null +++ b/app/components/UI/SliderButton/__snapshots__/index.test.js.snap @@ -0,0 +1,182 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SliderButton should render correctly 1`] = ` + + + + + + + Incomplete Text + + + + + + + Complete Text + + + + + +`; diff --git a/app/components/UI/SliderButton/assets/slider_button_gradient.png b/app/components/UI/SliderButton/assets/slider_button_gradient.png new file mode 100644 index 00000000000..92fdac2dc09 Binary files /dev/null and b/app/components/UI/SliderButton/assets/slider_button_gradient.png differ diff --git a/app/components/UI/SliderButton/assets/slider_button_shine.png b/app/components/UI/SliderButton/assets/slider_button_shine.png new file mode 100644 index 00000000000..428ec56e0f4 Binary files /dev/null and b/app/components/UI/SliderButton/assets/slider_button_shine.png differ diff --git a/app/components/UI/SliderButton/index.js b/app/components/UI/SliderButton/index.js new file mode 100644 index 00000000000..0080a54556d --- /dev/null +++ b/app/components/UI/SliderButton/index.js @@ -0,0 +1,259 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, Animated, PanResponder, StyleSheet, Image, Text } from 'react-native'; +import PropTypes from 'prop-types'; +import { colors, fontStyles } from '../../../styles/common'; + +/* eslint-disable import/no-commonjs */ +const SliderBgImg = require('./assets/slider_button_gradient.png'); +const SliderShineImg = require('./assets/slider_button_shine.png'); +/* eslint-enable import/no-commonjs */ + +const DIAMETER = 60; +const MARGIN = DIAMETER * 0.16; +const COMPLETE_VERTICAL_THRESHOLD = DIAMETER * 2; +const COMPLETE_THRESHOLD = 0.85; +const COMPLETE_DELAY = 1000; + +const styles = StyleSheet.create({ + container: { + shadowRadius: 8, + shadowOpacity: 0.5, + shadowColor: colors.blue200, + shadowOffset: { width: 0, height: 3 }, + elevation: 0 // shadow colors not supported on Android. nothing > gray shadow + }, + disabledContainer: { + opacity: 0.66 + }, + slider: { + position: 'absolute', + width: DIAMETER, + height: DIAMETER, + borderRadius: DIAMETER, + borderWidth: MARGIN, + borderColor: colors.blue600, + backgroundColor: colors.white + }, + trackBack: { + position: 'relative', + overflow: 'hidden', + width: '100%', + height: DIAMETER, + justifyContent: 'center', + alignItems: 'center', + borderRadius: DIAMETER, + backgroundColor: colors.blue700 + }, + trackBackGradient: { + position: 'absolute', + width: '100%', + height: '100%' + }, + trackBackGradientPressed: { + opacity: 0.66 + }, + trackBackShine: { + position: 'absolute', + height: '200%', + left: 0 + }, + trackFront: { + position: 'absolute', + overflow: 'hidden', + height: '100%', + borderRadius: DIAMETER + }, + + textFrontContainer: { + position: 'absolute', + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center' + }, + textBack: { + ...fontStyles.normal, + color: colors.white, + fontSize: 16 + }, + textFront: { + ...fontStyles.normal, + color: colors.white, + fontSize: 16 + } +}); + +function SliderButton({ incompleteText, completeText, onComplete, disabled }) { + const [componentWidth, setComponentWidth] = useState(0); + const [isComplete, setIsComplete] = useState(false); + const [shouldComplete, setShouldComplete] = useState(false); + const [isPressed, setIsPressed] = useState(false); + + const shineOffset = useRef(new Animated.Value(0)).current; + const pan = useRef(new Animated.ValueXY(0, 0)).current; + const completion = useRef(new Animated.Value(0)).current; + + const sliderPosition = useMemo( + () => + pan.x.interpolate({ + inputRange: [0, Math.max(componentWidth - DIAMETER, 0)], + outputRange: [0, componentWidth - DIAMETER], + extrapolate: 'clamp' + }), + [componentWidth, pan.x] + ); + + const incompleteTextOpacity = sliderPosition.interpolate({ + inputRange: [0, Math.max(componentWidth - DIAMETER, 0)], + outputRange: [1, 0] + }); + const shineOpacity = incompleteTextOpacity.interpolate({ + inputRange: [0, 0.5, 1], + outputRange: [0, 0, 1] + }); + const sliderCompletedOpacity = completion.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }); + const trackFrontBackgroundColor = completion.interpolate({ + inputRange: [0, 1], + outputRange: [colors.blue600, colors.success] + }); + + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => !disabled && !(shouldComplete || isComplete), + onMoveShouldSetPanResponder: () => !disabled && !(shouldComplete || isComplete), + onPanResponderGrant: () => setIsPressed(true), + onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }), + onPanResponderRelease: (evt, gestureState) => { + setIsPressed(false); + if ( + Math.abs(gestureState.dy) < COMPLETE_VERTICAL_THRESHOLD && + gestureState.dx / (componentWidth - DIAMETER) >= COMPLETE_THRESHOLD + ) { + setShouldComplete(true); + } else { + Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false }).start(); + } + } + }), + [componentWidth, disabled, isComplete, pan, shouldComplete] + ); + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(shineOffset, { toValue: 0, duration: 0, useNativeDriver: false }), + Animated.timing(shineOffset, { + toValue: 100, + duration: 2000, + useNativeDriver: false + }) + ]) + ).start(); + }, [shineOffset]); + + useEffect(() => { + if (!isComplete && shouldComplete) { + let completeTimeout; + Animated.parallel([ + Animated.spring(completion, { toValue: 1, useNativeDriver: false }), + Animated.spring(pan, { toValue: { x: componentWidth, y: 0 }, useNativeDriver: false }) + ]).start(() => { + completeTimeout = setTimeout(() => { + setIsComplete(true); + if (onComplete) { + onComplete(); + } + }, COMPLETE_DELAY); + }); + + return () => { + clearTimeout(completeTimeout); + }; + } + }, [completion, componentWidth, isComplete, onComplete, pan, shouldComplete]); + + return ( + { + setComponentWidth(e.nativeEvent.layout.width); + }} + > + + + {!disabled && ( + + )} + + {incompleteText} + + + + + {completeText} + + + + + ); +} + +SliderButton.propTypes = { + /** + * Text that prompts the user to interact with the slider + */ + incompleteText: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + /** + * Text during ineraction stating the action being taken + */ + completeText: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + /** + * Action to execute once button completes sliding + */ + onComplete: PropTypes.func, + /** + * Value that decides whether or not the slider is disabled + */ + disabled: PropTypes.bool +}; + +export default SliderButton; diff --git a/app/components/UI/SliderButton/index.test.js b/app/components/UI/SliderButton/index.test.js new file mode 100644 index 00000000000..6e6e125ba71 --- /dev/null +++ b/app/components/UI/SliderButton/index.test.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import SliderButton from './index'; + +describe('SliderButton', () => { + it('should render correctly', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/SlippageSlider/__snapshots__/index.test.js.snap b/app/components/UI/SlippageSlider/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000..1f421b08bdc --- /dev/null +++ b/app/components/UI/SlippageSlider/__snapshots__/index.test.js.snap @@ -0,0 +1,235 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SlippageSlider should render correctly 1`] = ` + + + + + + + + + + + + + + + + + undefined% + + + + +`; diff --git a/app/components/UI/SlippageSlider/index.js b/app/components/UI/SlippageSlider/index.js new file mode 100644 index 00000000000..e79c725f1c7 --- /dev/null +++ b/app/components/UI/SlippageSlider/index.js @@ -0,0 +1,271 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { View, Animated, PanResponder, StyleSheet, Text, Image } from 'react-native'; +import PropTypes from 'prop-types'; +import { colors, fontStyles } from '../../../styles/common'; + +/* eslint-disable import/no-commonjs */ +const SlippageSliderBgImg = require('../../../images/slippage-slider-bg.png'); +/* eslint-enable import/no-commonjs */ + +const DIAMETER = 30; +const TRACK_PADDING = 2; +const TICK_DIAMETER = 5; +const TOOLTIP_HEIGHT = 29; +const TAIL_WIDTH = 10; +const COMPONENT_HEIGHT = DIAMETER + TOOLTIP_HEIGHT + 10; + +const styles = StyleSheet.create({ + root: { + position: 'relative', + justifyContent: 'center', + height: COMPONENT_HEIGHT + }, + rootDisabled: { + opacity: 0.5 + }, + slider: { + position: 'absolute', + width: DIAMETER, + height: DIAMETER, + borderRadius: DIAMETER, + borderWidth: 1, + borderColor: colors.white, + bottom: 0, + shadowColor: colors.black, + shadowOffset: { + width: 0, + height: 0 + }, + shadowOpacity: 0.18, + shadowRadius: 14 + }, + trackBackContainer: { + position: 'absolute', + paddingHorizontal: DIAMETER / 2 - 2 * TRACK_PADDING, + bottom: DIAMETER / 2 - (TICK_DIAMETER + 2 * TRACK_PADDING) / 2 + }, + trackBack: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: TICK_DIAMETER + 2 * TRACK_PADDING, + backgroundColor: colors.blue000, + borderRadius: TICK_DIAMETER + 2 * TRACK_PADDING, + borderWidth: TRACK_PADDING, + borderColor: colors.blue000 + }, + tick: { + height: TICK_DIAMETER, + width: TICK_DIAMETER, + borderRadius: TICK_DIAMETER, + backgroundColor: colors.spinnerColor, + opacity: 0.5 + }, + trackFront: { + position: 'absolute', + overflow: 'hidden', + bottom: DIAMETER / 2 - (TICK_DIAMETER + 2 * TRACK_PADDING) / 2, + left: DIAMETER / 2 - 2 * TRACK_PADDING, + height: TICK_DIAMETER + 2 * TRACK_PADDING, + borderRadius: TICK_DIAMETER + 2 * TRACK_PADDING + }, + tooltipContainer: { + position: 'absolute', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.grey700, + padding: 5, + borderRadius: 8, + minHeight: TOOLTIP_HEIGHT, + minWidth: 40, + top: 0 + }, + tooltipTail: { + position: 'absolute', + left: 0, + right: 0, + bottom: -5, + width: TAIL_WIDTH, + height: TAIL_WIDTH, + backgroundColor: colors.grey700, + transform: [{ rotate: '45deg' }] + }, + tooltipText: { + ...fontStyles.normal, + color: colors.white, + fontSize: 12 + } +}); + +const setAnimatedValue = (animatedValue, value) => animatedValue.setValue(value); + +const SlippageSlider = ({ range, increment, onChange, value, formatTooltipText, disabled, changeOnRelease }) => { + /* Reusable/truncated references to the range prop values */ + const [r0, r1] = useMemo(() => range, [range]); + const fullRange = useMemo(() => r1 - r0, [r0, r1]); + const ticksLength = useMemo(() => Math.ceil(fullRange / increment), [fullRange, increment]); + + /* Layout State */ + const [trackWidth, setTrackWidth] = useState(0); + const [tooltipWidth, setTooltipWidth] = useState(0); + const [componentWidth, setComponentWidth] = useState(0); + + /* State */ + const [isResponderGranted, setIsResponderGranted] = useState(false); + const [temporaryValue, setTemporaryValue] = useState(value); + + /* Pan and slider position + /* Pan will handle the gesture and update slider */ + const pan = useRef(new Animated.Value(0)).current; + const slider = useRef(new Animated.Value(0)).current; + const sliderPosition = slider.interpolate({ + inputRange: [0, trackWidth], + outputRange: [0, trackWidth - DIAMETER], + extrapolate: 'clamp' + }); + + const sliderColor = sliderPosition.interpolate({ + inputRange: [0, trackWidth], + outputRange: [colors.spinnerColor, colors.red], + extrapolate: 'clamp' + }); + + /* Value effect, this updates the UI if the value prop changes */ + useEffect(() => { + if (!isResponderGranted) { + const relativePercent = ((value - r0) / fullRange) * trackWidth; + setAnimatedValue(slider, relativePercent); + pan.setValue(relativePercent); + } + }, [fullRange, isResponderGranted, pan, r0, slider, trackWidth, value]); + + /* Get the slider position value (snaps to points) and the value for the onChange callback */ + const getValuesByProgress = useCallback( + progressPercent => { + const multiplier = Math.round(progressPercent * ticksLength); + const sliderValue = (multiplier / ticksLength) * trackWidth; + const newValue = r0 + multiplier * increment; + return [sliderValue, newValue]; + }, + [increment, r0, ticksLength, trackWidth] + ); + + /* Slider action handlers */ + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => !disabled, + onMoveShouldSetPanResponder: () => !disabled, + onPanResponderGrant: () => { + setIsResponderGranted(true); + pan.setOffset(pan._value); + }, + /** + * When the slider is being dragged, this handler will figure out which tick + * it should snap to + */ + onPanResponderMove: (ev, gestureState) => { + pan.setValue(gestureState.dx); + const relativeValue = pan + .interpolate({ + inputRange: [0, trackWidth], + outputRange: [0, trackWidth], + extrapolate: 'clamp' + }) + .__getValue(); + + const [sliderValue, newValue] = getValuesByProgress(relativeValue / trackWidth); + if (!changeOnRelease) { + onChange(newValue); + } else { + setTemporaryValue(newValue); + } + + setAnimatedValue(slider, sliderValue); + }, + onPanResponderRelease: () => { + pan.flattenOffset(); + const relativeValue = Math.min(Math.max(0, pan._value), trackWidth); + pan.setValue(relativeValue); + if (changeOnRelease && onChange) { + const progress = relativeValue / trackWidth; + const [, newValue] = getValuesByProgress(progress); + onChange(newValue); + } + setIsResponderGranted(false); + } + }), + [changeOnRelease, disabled, getValuesByProgress, onChange, pan, slider, trackWidth] + ); + + /* Rendering */ + const displayValue = changeOnRelease && isResponderGranted ? temporaryValue : value; + + return ( + setComponentWidth(e.nativeEvent.layout.width)} + > + setTrackWidth(e.nativeEvent.layout.width)} + > + + {new Array(ticksLength + 1).fill().map((_, i) => ( + + ))} + + + + + + setTooltipWidth(e.nativeEvent.layout.width)} + > + + {formatTooltipText(displayValue)} + + + + ); +}; + +SlippageSlider.propTypes = { + /** + * Range of the slider + */ + range: PropTypes.arrayOf(PropTypes.number), + /** + * The increments between the range that are selectable + */ + increment: PropTypes.number, + /** + * Value for the slider + */ + value: PropTypes.number, + /** + * Action to execute when value changes + */ + onChange: PropTypes.func, + /** + * Function to format/compose the text in the tooltip + */ + formatTooltipText: PropTypes.func, + /** + * Value that decides whether or not the slider is disabled + */ + disabled: PropTypes.bool, + /** + * Wether to call onChange only on gesture release + */ + changeOnRelease: PropTypes.bool +}; + +export default SlippageSlider; diff --git a/app/components/UI/SlippageSlider/index.test.js b/app/components/UI/SlippageSlider/index.test.js new file mode 100644 index 00000000000..4beb52c8400 --- /dev/null +++ b/app/components/UI/SlippageSlider/index.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import SlippageSlider from './index'; + +describe('SlippageSlider', () => { + it('should render correctly', () => { + const wrapper = shallow( + undefined} + formatTooltipText={text => `${text}%`} + /> + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js new file mode 100644 index 00000000000..c9bcd384b22 --- /dev/null +++ b/app/components/UI/Swaps/QuotesView.js @@ -0,0 +1,687 @@ +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { connect } from 'react-redux'; +import IonicIcon from 'react-native-vector-icons/Ionicons'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import FAIcon from 'react-native-vector-icons/FontAwesome'; +import FA5Icon from 'react-native-vector-icons/FontAwesome5'; +import BigNumber from 'bignumber.js'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { NavigationContext } from 'react-navigation'; + +import Engine from '../../../core/Engine'; +import AppConstants from '../../../core/AppConstants'; +import Device from '../../../util/Device'; +import { colors } from '../../../styles/common'; +import { renderFromTokenMinimalUnit, renderFromWei, toWei, weiToFiat } from '../../../util/number'; +import { getErrorMessage, getFetchParams, getQuotesNavigationsParams, useRatio } from './utils'; + +import { getSwapsQuotesNavbar } from '../Navbar'; +import Text from '../../Base/Text'; +import Alert from '../../Base/Alert'; +import useModalHandler from '../../Base/hooks/useModalHandler'; +import ScreenView from '../FiatOrders/components/ScreenView'; +import StyledButton from '../StyledButton'; +import SliderButton from '../SliderButton'; +import TokenIcon from './components/TokenIcon'; +import QuotesSummary from './components/QuotesSummary'; +import FeeModal from './components/FeeModal'; +import QuotesModal from './components/QuotesModal'; +import { strings } from '../../../../locales/i18n'; +import { swapsUtils } from '@estebanmino/controllers'; +import useBalance from './utils/useBalance'; +import { fetchBasicGasEstimates } from '../../../util/custom-gas'; +import { addHexPrefix } from '@walletconnect/utils'; + +const POLLING_INTERVAL = AppConstants.SWAPS.POLLING_INTERVAL; + +const styles = StyleSheet.create({ + screen: { + flexGrow: 1, + justifyContent: 'space-between' + }, + topBar: { + alignItems: 'center', + marginVertical: 12 + }, + alertBar: { + paddingHorizontal: 20, + marginVertical: 10, + width: '100%' + }, + timerWrapper: { + backgroundColor: colors.grey000, + borderRadius: 20, + marginVertical: 12, + paddingVertical: 4, + paddingHorizontal: 15, + flexDirection: 'row', + alignItems: 'center' + }, + timer: { + fontVariant: ['tabular-nums'] + }, + timerHiglight: { + color: colors.red + }, + content: { + paddingHorizontal: 20, + alignItems: 'center' + }, + errorViewContent: { + flex: 1, + marginHorizontal: 55, + justifyContent: 'center' + }, + errorTitle: { + fontSize: 24, + marginVertical: 10 + }, + errorText: { + fontSize: 14 + }, + sourceTokenContainer: { + flexDirection: 'row', + alignItems: 'center' + }, + tokenIcon: { + marginHorizontal: 5 + }, + tokenText: { + color: colors.grey500, + fontSize: Device.isSmallDevice() ? 16 : 18 + }, + tokenTextDestination: { + color: colors.fontPrimary + }, + arrowDown: { + color: colors.grey100, + fontSize: Device.isSmallDevice() ? 22 : 25, + marginHorizontal: 15, + marginTop: Device.isSmallDevice() ? 2 : 4, + marginBottom: Device.isSmallDevice() ? 0 : 2 + }, + amount: { + textAlignVertical: 'center', + fontSize: Device.isSmallDevice() ? 45 : 60, + marginBottom: Device.isSmallDevice() ? 8 : 24 + }, + exchangeRate: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: Device.isSmallDevice() ? 1 : 1 + }, + bottomSection: { + marginBottom: 12, + alignItems: 'stretch', + paddingHorizontal: 20 + }, + sliderButtonText: { + fontSize: 16, + color: colors.white + }, + quotesSummary: { + marginVertical: Device.isSmallDevice() ? 12 : 24 + }, + quotesSummaryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + flexWrap: 'wrap' + }, + quotesRow: { + flexDirection: 'row' + }, + quotesDescription: { + flex: 1, + flexWrap: 'wrap', + flexDirection: 'row', + justifyContent: 'space-between', + marginRight: 6 + }, + quotesLegend: { + flexDirection: 'row', + flexWrap: 'wrap', + marginRight: 2 + }, + quotesFiatColumn: { + alignItems: 'flex-end', + justifyContent: 'center' + }, + infoIcon: { + fontSize: 12, + margin: 3, + color: colors.blue + }, + ctaButton: { + width: '100%' + }, + errorIcon: { + fontSize: 46, + marginVertical: 4, + color: colors.red + }, + expiredIcon: { + color: colors.blue, + fontSize: 50 + } +}); + +async function resetAndStartPolling({ slippage, sourceToken, destinationToken, sourceAmount, walletAddress }) { + const { SwapsController, TokenRatesController, AssetsController } = Engine.context; + const contractExchangeRates = TokenRatesController.state.contractExchangeRates; + // ff the token is not in the wallet, we'll add it + if ( + destinationToken.address !== swapsUtils.ETH_SWAPS_TOKEN_ADDRESS && + !contractExchangeRates[toChecksumAddress(destinationToken.address)] + ) { + const { address, symbol, decimals } = destinationToken; + await AssetsController.addToken(address, symbol, decimals); + await new Promise(resolve => + setTimeout(() => { + resolve(); + }, 500) + ); + } + const destinationTokenConversionRate = + TokenRatesController.state.contractExchangeRates[toChecksumAddress(destinationToken.address)] || 0; + + // TODO: destinationToken could be the 0 address for ETH, also tokens that aren't on the wallet + const fetchParams = getFetchParams({ + slippage, + sourceToken, + destinationToken, + sourceAmount, + walletAddress, + destinationTokenConversionRate + }); + await SwapsController.stopPollingAndResetState(); + await SwapsController.startFetchAndSetQuotes(fetchParams, fetchParams.metaData); +} + +function SwapsQuotesView({ + tokens, + accounts, + balances, + selectedAddress, + currentCurrency, + conversionRate, + isInPolling, + isInFetch, + quotesLastFetched, + pollingCyclesLeft, + approvalTransaction, + topAggId, + quotes, + quoteValues, + errorKey +}) { + const navigation = useContext(NavigationContext); + + /* Get params from navigation */ + const { sourceTokenAddress, destinationTokenAddress, sourceAmount, slippage } = useMemo( + () => getQuotesNavigationsParams(navigation), + [navigation] + ); + + /* Get tokens from the tokens list */ + const sourceToken = tokens?.find(token => token.address?.toLowerCase() === sourceTokenAddress.toLowerCase()); + const destinationToken = tokens?.find( + token => token.address?.toLowerCase() === destinationTokenAddress.toLowerCase() + ); + + /* Balance */ + const balance = useBalance(accounts, balances, selectedAddress, sourceToken, { asUnits: true }); + const [hasEnoughBalance, missingBalance] = useMemo(() => { + const sourceBN = new BigNumber(sourceAmount); + const balanceBN = new BigNumber(balance); + const hasEnough = balanceBN.gte(sourceBN); + return [hasEnough, hasEnough ? null : sourceBN.minus(balanceBN)]; + }, [balance, sourceAmount]); + + /* State */ + const [firstLoadTime, setFirstLoadTime] = useState(Date.now()); + const [isFirstLoad, setFirstLoad] = useState(true); + const [remainingTime, setRemainingTime] = useState(POLLING_INTERVAL); + const [basicGasEstimates, setBasicGasEstimates] = useState({}); + + /* Selected quote, initially topAggId (see effects) */ + const [selectedQuoteId, setSelectedQuoteId] = useState(null); + + /* Get quotes as an array sorted by overallValue */ + const allQuotes = useMemo(() => { + if (!quotes || !quoteValues || Object.keys(quotes).length === 0 || Object.keys(quoteValues).length === 0) { + return []; + } + + const orderedValues = Object.values(quoteValues).sort( + (a, b) => Number(b.overallValueOfQuote) - Number(a.overallValueOfQuote) + ); + + return orderedValues.map(quoteValue => quotes[quoteValue.aggregator]); + }, [quoteValues, quotes]); + + /* Get the selected quote, by default is topAggId */ + const selectedQuote = useMemo(() => allQuotes.find(quote => quote.aggregator === selectedQuoteId), [ + allQuotes, + selectedQuoteId + ]); + + const selectedQuoteValue = useMemo(() => quoteValues[selectedQuoteId], [quoteValues, selectedQuoteId]); + + // TODO: use this variable in the future when calculating savings + const [isSaving] = useState(false); + + /* Get the ratio between the assets given the selected quote*/ + const [ratioAsSource, setRatioAsSource] = useState(true); + + const [numerator, denominator] = useMemo(() => { + const source = { ...sourceToken, amount: selectedQuote?.sourceAmount }; + const destination = { ...destinationToken, amount: selectedQuote?.destinationAmount }; + + return ratioAsSource ? [destination, source] : [source, destination]; + }, [destinationToken, ratioAsSource, selectedQuote, sourceToken]); + + const ratio = useRatio(numerator?.amount, numerator?.decimals, denominator?.amount, denominator?.decimals); + + /* Modals, state and handlers */ + const [isFeeModalVisible, toggleFeeModal, , hideFeeModal] = useModalHandler(false); + const [isQuotesModalVisible, toggleQuotesModal, , hideQuotesModal] = useModalHandler(false); + + /* Handlers */ + const handleRatioSwitch = () => setRatioAsSource(isSource => !isSource); + + const handleRetryFetchQuotes = useCallback(() => { + if (errorKey === swapsUtils.SwapsError.QUOTES_EXPIRED_ERROR) { + navigation.setParams({ leftAction: strings('navigation.back') }); + setFirstLoadTime(Date.now()); + setFirstLoad(true); + resetAndStartPolling({ + slippage, + sourceToken, + destinationToken, + sourceAmount, + fromAddress: selectedAddress + }); + } else { + navigation.pop(); + } + }, [errorKey, slippage, sourceToken, destinationToken, sourceAmount, selectedAddress, navigation]); + + const handleCompleteSwap = useCallback(async () => { + if (!selectedQuote) { + return; + } + const { TransactionController } = Engine.context; + if (basicGasEstimates?.average) { + const averageGasPrice = addHexPrefix(basicGasEstimates.average.toString(16)); + if (approvalTransaction) { + approvalTransaction.gasPrice = averageGasPrice; + } + selectedQuote.trade.gasPrice = averageGasPrice; + } + + if (approvalTransaction) { + await TransactionController.addTransaction(approvalTransaction); + } + await TransactionController.addTransaction(selectedQuote.trade); + navigation.dismiss(); + }, [navigation, selectedQuote, approvalTransaction, basicGasEstimates]); + + /* Effects */ + + /* Main polling effect */ + useEffect(() => { + resetAndStartPolling({ + slippage, + sourceToken, + destinationToken, + sourceAmount, + walletAddress: selectedAddress + }); + return () => { + const { SwapsController } = Engine.context; + SwapsController.stopPollingAndResetState(); + }; + }, [destinationToken, selectedAddress, slippage, sourceAmount, sourceToken]); + + /* First load effect: handle initial animation */ + useEffect(() => { + if (isFirstLoad) { + if (firstLoadTime < quotesLastFetched || errorKey) { + setFirstLoad(false); + if (!errorKey) { + navigation.setParams({ leftAction: strings('swaps.edit') }); + } + } + } + }, [errorKey, firstLoadTime, isFirstLoad, navigation, quotesLastFetched]); + + /* selectedQuoteId effect: when topAggId changes make it selected by default */ + useEffect(() => setSelectedQuoteId(topAggId), [topAggId]); + + useEffect(() => { + const setGasPriceEstimates = async () => { + const basicGasEstimates = await fetchBasicGasEstimates(); + setBasicGasEstimates(basicGasEstimates); + }; + setGasPriceEstimates(); + }, []); + + /* IsInFetch effect: hide every modal, handle countdown */ + useEffect(() => { + if (isInFetch) { + setRemainingTime(POLLING_INTERVAL); + hideFeeModal(); + hideQuotesModal(); + return; + } + const tick = setInterval(() => { + setRemainingTime(quotesLastFetched + POLLING_INTERVAL - Date.now() + 1000); + }, 1000); + return () => { + clearInterval(tick); + }; + }, [hideFeeModal, hideQuotesModal, isInFetch, quotesLastFetched]); + + /* errorKey effect: hide every modal*/ + useEffect(() => { + if (errorKey) { + hideFeeModal(); + hideQuotesModal(); + } + }, [errorKey, hideFeeModal, hideQuotesModal]); + + /* Rendering */ + if (isFirstLoad || (!errorKey && !selectedQuote)) { + return ( + + + + + + ); + } + + if (!isInPolling && errorKey) { + const [errorTitle, errorMessage, errorAction] = getErrorMessage(errorKey); + const errorIcon = + errorKey === swapsUtils.SwapsError.QUOTES_EXPIRED_ERROR ? ( + + ) : ( + + ); + + return ( + + + {errorIcon} + + {errorTitle} + + + {errorMessage} + + + + + {errorAction} + + + + ); + } + + return ( + + + {!hasEnoughBalance && ( + + + {strings('swaps.you_need')}{' '} + + {renderFromTokenMinimalUnit(missingBalance, sourceToken.decimals)} {sourceToken.symbol} + {' '} + {strings('swaps.more_to_complete')} + + + )} + {isInPolling && ( + + {isInFetch ? ( + <> + + {strings('swaps.fetching_new_quotes')} + + ) : ( + + {pollingCyclesLeft > 0 + ? strings('swaps.new_quotes_in') + : strings('swaps.quotes_expire_in')}{' '} + + {new Date(remainingTime).toISOString().substr(15, 4)} + + + )} + + )} + {!isInPolling && ( + + ... + + )} + + + + {selectedQuote && ( + <> + + + {renderFromTokenMinimalUnit(selectedQuote.sourceAmount, sourceToken.decimals)} + + + {sourceToken.symbol} + + + + + + {destinationToken.symbol} + + + + ~{renderFromTokenMinimalUnit(selectedQuote.destinationAmount, destinationToken.decimals)} + + + + + 1 {denominator?.symbol} = {ratio.toFormat(10)} {numerator?.symbol}{' '} + + + + + + )} + + + + {selectedQuote && ( + + + + {isSaving ? strings('swaps.savings') : strings('swaps.using_best_quote')} + + + + {strings('swaps.view_details')} → + + + + + + + + + {strings('swaps.estimated_gas_fee')} + + + + {renderFromWei(toWei(selectedQuoteValue.ethFee))} ETH + + + + + {weiToFiat(toWei(selectedQuoteValue.ethFee), conversionRate, currentCurrency)} + + + + + + + + {strings('swaps.max_gas_fee')} + {/* TODO: allow max gas fee edit in the future */} + {/* + {strings('swaps.edit')} + */} + + {renderFromWei(toWei(selectedQuoteValue.maxEthFee))} ETH + + + + {weiToFiat( + toWei(selectedQuoteValue.maxEthFee), + conversionRate, + currentCurrency + )} + + + + + {approvalTransaction && ( + + + + {`${strings('swaps.enable.this_will')} `} + + {`${strings('swaps.enable.enable_asset', { + asset: sourceToken.symbol + })} `} + + {`${strings('swaps.enable.for_swapping')}`} + {/* TODO: allow token spend limit in the future */} + {/* {` ${strings('swaps.enable.edit_limit')}`} */} + + + + )} + + + + + {`${strings('swaps.quotes_include_fee', { fee: selectedQuote.fee })} `} + + + + + + + )} + + {strings('swaps.swipe_to')}{' '} + + {strings('swaps.swap')} + + + } + completeText={{strings('swaps.completed_swap')}} + disabled={!isInPolling || isInFetch || !selectedQuote || !hasEnoughBalance} + onComplete={handleCompleteSwap} + /> + + + + + + ); +} + +SwapsQuotesView.propTypes = { + tokens: PropTypes.arrayOf(PropTypes.object), + /** + * Map of accounts to information objects including balances + */ + accounts: PropTypes.object, + /** + * An object containing token balances for current account and network in the format address => balance + */ + balances: PropTypes.object, + /** + * ETH to current currency conversion rate + */ + conversionRate: PropTypes.number, + /** + * Currency code of the currently-active currency + */ + currentCurrency: PropTypes.string, + /** + * A string that represents the selected address + */ + selectedAddress: PropTypes.string, + isInPolling: PropTypes.bool, + isInFetch: PropTypes.bool, + quotesLastFetched: PropTypes.number, + topAggId: PropTypes.string, + pollingCyclesLeft: PropTypes.number, + quotes: PropTypes.object, + quoteValues: PropTypes.object, + approvalTransaction: PropTypes.object, + errorKey: PropTypes.string +}; + +SwapsQuotesView.navigationOptions = ({ navigation }) => getSwapsQuotesNavbar(navigation); + +const mapStateToProps = state => ({ + accounts: state.engine.backgroundState.AccountTrackerController.accounts, + selectedAddress: state.engine.backgroundState.PreferencesController.selectedAddress, + balances: state.engine.backgroundState.TokenBalancesController.contractBalances, + conversionRate: state.engine.backgroundState.CurrencyRateController.conversionRate, + currentCurrency: state.engine.backgroundState.CurrencyRateController.currentCurrency, + tokens: state.engine.backgroundState.SwapsController.tokens, + isInPolling: state.engine.backgroundState.SwapsController.isInPolling, + isInFetch: state.engine.backgroundState.SwapsController.isInFetch, + quotesLastFetched: state.engine.backgroundState.SwapsController.quotesLastFetched, + pollingCyclesLeft: state.engine.backgroundState.SwapsController.pollingCyclesLeft, + topAggId: state.engine.backgroundState.SwapsController.topAggId, + quotes: state.engine.backgroundState.SwapsController.quotes, + quoteValues: state.engine.backgroundState.SwapsController.quoteValues, + approvalTransaction: state.engine.backgroundState.SwapsController.approvalTransaction, + errorKey: state.engine.backgroundState.SwapsController.errorKey +}); + +export default connect(mapStateToProps)(SwapsQuotesView); diff --git a/app/components/UI/Swaps/components/FeeModal.js b/app/components/UI/Swaps/components/FeeModal.js new file mode 100644 index 00000000000..6ab0164728b --- /dev/null +++ b/app/components/UI/Swaps/components/FeeModal.js @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-navigation'; +import Modal from 'react-native-modal'; +import IonicIcon from 'react-native-vector-icons/Ionicons'; +import { strings } from '../../../../../locales/i18n'; + +import Title from '../../../Base/Title'; +import Text from '../../../Base/Text'; +import { colors } from '../../../../styles/common'; + +const styles = StyleSheet.create({ + modalView: { + backgroundColor: colors.white, + justifyContent: 'center', + alignItems: 'center', + marginVertical: 50, + borderRadius: 10, + shadowColor: colors.black, + shadowOffset: { + width: 0, + height: 5 + }, + shadowOpacity: 0.36, + shadowRadius: 6.68, + elevation: 11 + }, + modal: { + margin: 0, + width: '100%', + padding: 25 + }, + title: { + width: '100%', + paddingVertical: 15, + paddingHorizontal: 20, + paddingBottom: 5, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + closeIcon: { + color: colors.black + }, + body: { + width: '100%', + paddingVertical: 5, + marginBottom: 15, + paddingHorizontal: 20 + } +}); + +function FeeModal({ fee = '0.875%', isVisible, toggleModal }) { + return ( + + + + {strings('swaps.metamask_swap_fee')} + + + + + + + {strings('swaps.fee_text.get_the')} {strings('swaps.fee_text.best_price')}{' '} + {strings('swaps.fee_text.from_the')} {strings('swaps.fee_text.top_liquidity')}{' '} + {strings('swaps.fee_text.fee_is_applied', { fee })} + + + + + ); +} +FeeModal.propTypes = { + fee: PropTypes.string, + isVisible: PropTypes.bool, + toggleModal: PropTypes.func +}; + +export default FeeModal; diff --git a/app/components/UI/Swaps/components/QuotesModal.js b/app/components/UI/Swaps/components/QuotesModal.js new file mode 100644 index 00000000000..b10af74d62a --- /dev/null +++ b/app/components/UI/Swaps/components/QuotesModal.js @@ -0,0 +1,242 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View, ScrollView, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-navigation'; +import Modal from 'react-native-modal'; +import IonicIcon from 'react-native-vector-icons/Ionicons'; +import { strings } from '../../../../../locales/i18n'; + +import Title from '../../../Base/Title'; +import { colors } from '../../../../styles/common'; +import Text from '../../../Base/Text'; +import { renderFromTokenMinimalUnit, toWei, weiToFiat } from '../../../../util/number'; +import { connect } from 'react-redux'; + +const styles = StyleSheet.create({ + modalView: { + backgroundColor: colors.white, + justifyContent: 'center', + alignItems: 'center', + marginVertical: 50, + borderRadius: 10, + shadowColor: colors.black, + shadowOffset: { + width: 0, + height: 5 + }, + shadowOpacity: 0.36, + shadowRadius: 6.68, + elevation: 11 + }, + modal: { + margin: 0, + width: '100%', + padding: 25 + }, + title: { + width: '100%', + paddingVertical: 15, + paddingHorizontal: 20, + paddingBottom: 5, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + closeIcon: { + color: colors.black + }, + body: { + width: '100%', + paddingVertical: 5 + }, + row: { + paddingHorizontal: 20, + flexDirection: 'row', + paddingVertical: 10 + }, + quoteRow: { + borderTopWidth: 1, + borderTopColor: colors.grey100, + paddingVertical: 15, + alignItems: 'center' + }, + selectedQuoteRow: { + backgroundColor: colors.blue000 + }, + columnAmount: { + flex: 3, + marginRight: 5 + }, + columnFee: { + flex: 3, + marginRight: 5 + }, + columnValue: { + flex: 3, + marginRight: 5 + }, + red: { + color: colors.red + }, + bestBadge: { + flexDirection: 'row' + }, + bestBadgeWrapper: { + paddingVertical: 0, + paddingHorizontal: 8, + backgroundColor: colors.blue, + borderRadius: 4 + }, + bestBadgeText: { + color: colors.white + } +}); + +function QuotesModal({ + isVisible, + toggleModal, + quotes, + selectedQuote, + destinationToken, + conversionRate, + currentCurrency, + quoteValues +}) { + const bestOverallValue = quoteValues[quotes[0].aggregator].overallValueOfQuote; + return ( + + + + {strings('swaps.quotes_overview')} + + + + + + true}> + + + + + {destinationToken.symbol} + + + {strings('swaps.receiving')} + + + + + {strings('swaps.estimated_gas_fee')} + + + + + {strings('swaps.overall_value')} + + + + + {quotes.length > 0 && + quotes.map((quote, index) => { + const { aggregator } = quote; + const isSelected = aggregator === selectedQuote; + return ( + + + + ~ + {renderFromTokenMinimalUnit( + quote.destinationAmount, + destinationToken.decimals + )} + + + + + {weiToFiat( + toWei(quoteValues[aggregator].ethFee), + conversionRate, + currentCurrency + )} + + + + {index === 0 ? ( + + + + {strings('swaps.best')} + + + + ) : ( + + - + {weiToFiat( + toWei( + ( + bestOverallValue - + quoteValues[aggregator].overallValueOfQuote + ).toFixed(18) + ), + conversionRate, + currentCurrency + )} + + )} + + + + ); + })} + + + + + + + ); +} + +QuotesModal.propTypes = { + isVisible: PropTypes.bool, + toggleModal: PropTypes.func, + quotes: PropTypes.array, + selectedQuote: PropTypes.string, + destinationToken: PropTypes.shape({ + symbol: PropTypes.string, + decimals: PropTypes.number + }), + /** + * ETH to current currency conversion rate + */ + conversionRate: PropTypes.number, + /** + * Currency code of the currently-active currency + */ + currentCurrency: PropTypes.string, + quoteValues: PropTypes.object +}; + +const mapStateToProps = state => ({ + conversionRate: state.engine.backgroundState.CurrencyRateController.conversionRate, + currentCurrency: state.engine.backgroundState.CurrencyRateController.currentCurrency, + quoteValues: state.engine.backgroundState.SwapsController.quoteValues +}); + +export default connect(mapStateToProps)(QuotesModal); diff --git a/app/components/UI/Swaps/components/QuotesSummary.js b/app/components/UI/Swaps/components/QuotesSummary.js new file mode 100644 index 00000000000..13185f996b3 --- /dev/null +++ b/app/components/UI/Swaps/components/QuotesSummary.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet, Image } from 'react-native'; +import { colors } from '../../../../styles/common'; +import Text from '../../../Base/Text'; + +// eslint-disable-next-line import/no-commonjs +const piggyBank = require('../../../../images/piggybank.png'); + +const styles = StyleSheet.create({ + header: { + paddingVertical: 10, + paddingHorizontal: 15, + borderWidth: 1, + borderColor: colors.blue, + borderTopRightRadius: 10, + borderTopLeftRadius: 10, + backgroundColor: colors.blue000 + }, + headerWithPiggy: { + paddingLeft: 15 + 32 + 10 + }, + piggyBar: { + position: 'absolute', + top: -1, + left: 21, + height: 0, + width: 19, + borderTopWidth: 1, + borderColor: colors.blue000 + }, + piggyBank: { + position: 'absolute', + top: -12, + left: 15, + width: 32, + height: 44 + }, + headerText: { + color: colors.blue + }, + body: { + paddingVertical: 10, + paddingHorizontal: 15, + borderWidth: 1, + borderTopWidth: 0, + borderColor: colors.blue, + borderBottomRightRadius: 10, + borderBottomLeftRadius: 10 + }, + separator: { + height: 0, + width: '100%', + borderTopWidth: 1, + marginVertical: 6, + borderTopColor: colors.grey100 + } +}); + +const QuotesSummary = props => ; + +const Header = ({ style, savings, children, ...props }) => ( + + {savings && ( + <> + + + + )} + {children} + +); + +const Body = ({ style, ...props }) => ; +const HeaderText = ({ style, ...props }) => ; +const Separator = ({ style }) => ; + +QuotesSummary.Body = Body; +QuotesSummary.Header = Header; +QuotesSummary.HeaderText = HeaderText; +QuotesSummary.Separator = Separator; + +Header.propTypes = { + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + /** Wether the piggybank is shown or not */ + savings: PropTypes.bool, + children: PropTypes.node +}; + +Body.propTypes = { + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]) +}; + +HeaderText.propTypes = { + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]) +}; + +Separator.propTypes = { + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]) +}; + +export default QuotesSummary; diff --git a/app/components/UI/Swaps/components/SlippageModal.js b/app/components/UI/Swaps/components/SlippageModal.js new file mode 100644 index 00000000000..c3199654ac7 --- /dev/null +++ b/app/components/UI/Swaps/components/SlippageModal.js @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, SafeAreaView, View } from 'react-native'; +import Modal from 'react-native-modal'; +import { colors } from '../../../../styles/common'; + +import ModalDragger from '../../../Base/ModalDragger'; +import Text from '../../../Base/Text'; +import SlippageSlider from '../../SlippageSlider'; +import { strings } from '../../../../../locales/i18n'; + +const styles = StyleSheet.create({ + modal: { + margin: 0, + justifyContent: 'flex-end' + }, + modalView: { + backgroundColor: colors.white, + borderTopLeftRadius: 10, + borderTopRightRadius: 10 + }, + content: { + marginVertical: 14, + paddingHorizontal: 30 + }, + slippageWrapper: { + marginVertical: 10 + }, + warningTextWrapper: { + position: 'absolute', + width: '85%', + bottom: 30, + left: 10 + }, + warningText: { + color: colors.red + } +}); + +function SlippageModal({ isVisible, dismiss, onChange, slippage }) { + return ( + + + + + + {strings('swaps.max_slippage')} + + + + + {slippage >= 5 && ( + {strings('swaps.slippage_warning')} + )} + + `${text}%`} + /> + + + + {strings('swaps.slippage_info')} + + + + + ); +} + +SlippageModal.propTypes = { + isVisible: PropTypes.bool, + dismiss: PropTypes.func, + onChange: PropTypes.func, + slippage: PropTypes.number +}; +export default SlippageModal; diff --git a/app/components/UI/Swaps/components/TokenIcon.js b/app/components/UI/Swaps/components/TokenIcon.js index 238af4cec8d..5652bbf5b49 100644 --- a/app/components/UI/Swaps/components/TokenIcon.js +++ b/app/components/UI/Swaps/components/TokenIcon.js @@ -40,34 +40,36 @@ const styles = StyleSheet.create({ } }); -const EmptyIcon = ({ medium, ...props }) => ( - +const EmptyIcon = ({ medium, style, ...props }) => ( + ); EmptyIcon.propTypes = { - medium: PropTypes.bool + medium: PropTypes.bool, + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]) }; -function TokenIcon({ symbol, icon, medium }) { +function TokenIcon({ symbol, icon, medium, style }) { if (symbol === 'ETH') { - return ; + return ; } else if (icon) { - return ; + return ; } else if (symbol) { return ( - + {symbol[0].toUpperCase()} ); } - return ; + return ; } TokenIcon.propTypes = { symbol: PropTypes.string, icon: PropTypes.string, - medium: PropTypes.bool + medium: PropTypes.bool, + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]) }; export default TokenIcon; diff --git a/app/components/UI/Swaps/components/TokenSelectModal.js b/app/components/UI/Swaps/components/TokenSelectModal.js index ef7b68cb39f..9603be6221a 100644 --- a/app/components/UI/Swaps/components/TokenSelectModal.js +++ b/app/components/UI/Swaps/components/TokenSelectModal.js @@ -5,7 +5,6 @@ import { FlatList } from 'react-native-gesture-handler'; import Modal from 'react-native-modal'; import Icon from 'react-native-vector-icons/Ionicons'; import Fuse from 'fuse.js'; -import useKeyboard from '@rnhooks/keyboard'; import { toChecksumAddress } from 'ethereumjs-util'; import { connect } from 'react-redux'; @@ -19,8 +18,6 @@ import ListItem from '../../../Base/ListItem'; import ModalDragger from '../../../Base/ModalDragger'; import TokenIcon from './TokenIcon'; -const isAndroid = Device.isAndroid(); - const styles = StyleSheet.create({ modal: { margin: 0, @@ -54,12 +51,9 @@ const styles = StyleSheet.create({ marginBottom: Device.isIphone5() ? 5 : 5 }, resultsView: { - height: Device.isSmallDevice() ? 200 : Device.isMediumDevice() ? 200 : 280, + height: Device.isSmallDevice() ? 200 : 280, marginTop: 10 }, - resultsViewKeyboardOpen: { - height: Device.isSmallDevice() ? 120 : Device.isMediumDevice() ? 200 : 280 - }, resultRow: { borderTopWidth: StyleSheet.hairlineWidth, borderColor: colors.grey100 @@ -85,7 +79,6 @@ function TokenSelectModal({ balances }) { const searchInput = useRef(null); - const [isKeyboardVisible] = useKeyboard({ useWillShow: !isAndroid, useWillHide: !isAndroid }); const [searchString, setSearchString] = useState(''); const filteredTokens = useMemo(() => tokens?.filter(token => !exclude.includes(token.symbol)), [tokens, exclude]); @@ -184,7 +177,7 @@ function TokenSelectModal({ /> @@ -70,6 +71,7 @@ exports[`TokenIcon component should Render correctly 4`] = ` "width": 24, }, undefined, + undefined, ] } /> @@ -101,6 +103,7 @@ exports[`TokenIcon component should Render correctly 6`] = ` "height": 36, "width": 36, }, + undefined, ] } /> @@ -161,6 +164,7 @@ exports[`TokenIcon component should Render correctly 8`] = ` "height": 36, "width": 36, }, + undefined, ] } /> diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index 18e1d8f59f7..89199e56bee 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -6,10 +6,14 @@ import { NavigationContext } from 'react-navigation'; import IonicIcon from 'react-native-vector-icons/Ionicons'; import BigNumber from 'bignumber.js'; import { toChecksumAddress } from 'ethereumjs-util'; +import { swapsUtils } from '@estebanmino/controllers'; + import Engine from '../../../core/Engine'; import useModalHandler from '../../Base/hooks/useModalHandler'; import Device from '../../../util/Device'; -import { renderFromTokenMinimalUnit, renderFromWei } from '../../../util/number'; +import { fromTokenMinimalUnit, toTokenMinimalUnit } from '../../../util/number'; +import { setQuotesNavigationsParams } from './utils'; + import { strings } from '../../../../locales/i18n'; import { colors } from '../../../styles/common'; @@ -20,6 +24,9 @@ import StyledButton from '../StyledButton'; import ScreenView from '../FiatOrders/components/ScreenView'; import TokenSelectButton from './components/TokenSelectButton'; import TokenSelectModal from './components/TokenSelectModal'; +import SlippageModal from './components/SlippageModal'; +import useBalance from './utils/useBalance'; +import AppConstants from '../../../core/AppConstants'; const styles = StyleSheet.create({ screen: { @@ -52,6 +59,9 @@ const styles = StyleSheet.create({ amountInvalid: { color: colors.red }, + linkText: { + color: colors.blue + }, horizontalRuleContainer: { flexDirection: 'row', paddingHorizontal: 30, @@ -80,9 +90,6 @@ const styles = StyleSheet.create({ column: { flex: 1 }, - disabledSlippage: { - color: colors.grey300 - }, ctaContainer: { flexDirection: 'row', justifyContent: 'flex-end' @@ -92,19 +99,26 @@ const styles = StyleSheet.create({ } }); +// Grab this from SwapsController.utils +const SWAPS_ETH_ADDRESS = swapsUtils.ETH_SWAPS_TOKEN_ADDRESS; + function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { const navigation = useContext(NavigationContext); - const initialSource = navigation.getParam('sourceToken', 'ETH'); + const initialSource = navigation.getParam('sourceToken', SWAPS_ETH_ADDRESS); const [amount, setAmount] = useState('0'); + const [slippage, setSlippage] = useState(AppConstants.SWAPS.DEFAULT_SLIPPAGE); const amountBigNumber = useMemo(() => new BigNumber(amount), [amount]); const [isInitialLoadingTokens, setInitialLoadingTokens] = useState(false); const [, setLoadingTokens] = useState(false); - const [sourceToken, setSourceToken] = useState(() => tokens?.find(token => token.symbol === initialSource)); + const [sourceToken, setSourceToken] = useState(() => + tokens?.find(token => token.address?.toLowerCase() === initialSource.toLowerCase()) + ); const [destinationToken, setDestinationToken] = useState(null); const [isSourceModalVisible, toggleSourceModal] = useModalHandler(false); const [isDestinationModalVisible, toggleDestinationModal] = useModalHandler(false); + const [isSlippageModalVisible, toggleSlippageModal] = useModalHandler(false); const hasInvalidDecimals = useMemo(() => { if (sourceToken) { @@ -135,23 +149,36 @@ function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { useEffect(() => { if (initialSource && tokens && !sourceToken) { - setSourceToken(tokens.find(token => token.symbol === initialSource)); + setSourceToken(tokens.find(token => token.address?.toLowerCase() === initialSource.toLowerCase())); } }, [tokens, initialSource, sourceToken]); - const balance = useMemo(() => { - if (!sourceToken) { - return null; - } - if (sourceToken.symbol === 'ETH') { - return renderFromWei(accounts[selectedAddress] && accounts[selectedAddress].balance); + const balance = useBalance(accounts, balances, selectedAddress, sourceToken); + + const hasBalance = useMemo(() => { + if (!balance || !sourceToken || sourceToken.symbol === 'ETH') { + return false; } - const tokenAddress = toChecksumAddress(sourceToken.address); - return tokenAddress in balances ? renderFromTokenMinimalUnit(balances[tokenAddress], sourceToken.decimals) : 0; - }, [accounts, balances, selectedAddress, sourceToken]); + return new BigNumber(balance).gt(0); + }, [balance, sourceToken]); const hasEnoughBalance = useMemo(() => amountBigNumber.lte(new BigNumber(balance)), [amountBigNumber, balance]); + /* Navigation handler */ + const handleGetQuotesPress = useCallback( + () => + navigation.navigate( + 'SwapsQuotesView', + setQuotesNavigationsParams( + sourceToken?.address, + destinationToken?.address, + toTokenMinimalUnit(amount, sourceToken?.decimals).toString(), + slippage + ) + ), + [amount, destinationToken, navigation, slippage, sourceToken] + ); + /* Keypad Handlers */ const handleKeypadChange = useCallback( value => { @@ -179,6 +206,17 @@ function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { [toggleDestinationModal] ); + const handleUseMax = useCallback(() => { + if (!sourceToken) { + return; + } + setAmount(fromTokenMinimalUnit(balances[toChecksumAddress(sourceToken.address)], sourceToken.decimals)); + }, [balances, sourceToken]); + + const handleSlippageChange = useCallback(value => { + setSlippage(value); + }, []); + return ( @@ -218,10 +256,18 @@ function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { })} ) : ( - - {sourceToken && balance !== null - ? strings('swaps.available_to_swap', { asset: `${balance} ${sourceToken.symbol}` }) - : ''} + + {sourceToken && + balance !== null && + strings('swaps.available_to_swap', { + asset: `${balance} ${sourceToken.symbol}` + })} + {hasBalance && ( + + {' '} + {strings('swaps.use_max')} + + )} )} @@ -255,9 +301,9 @@ function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { - - - {strings('swaps.max_slippage', { slippage: '1%' })} + + + {strings('swaps.max_slippage_amount', { slippage: `${slippage}%` })} @@ -265,6 +311,7 @@ function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { + ); } diff --git a/app/components/UI/Swaps/utils/index.js b/app/components/UI/Swaps/utils/index.js new file mode 100644 index 00000000000..2ae44aeb57b --- /dev/null +++ b/app/components/UI/Swaps/utils/index.js @@ -0,0 +1,106 @@ +import { useMemo } from 'react'; +import BigNumber from 'bignumber.js'; +import { swapsUtils } from '@estebanmino/controllers'; +import { strings } from '../../../../../locales/i18n'; + +/** + * Sets required parameters for Swaps Quotes View + * @param {string} sourceTokenAddress Token contract address used as swaps source + * @param {string} destinationTokenAddress Token contract address used as swaps result + * @param {string} sourceAmount Amount in minimal token units of sourceTokenAddress to be swapped + * @param {string|number} slippage Max slippage + * @return {object} Object containing sourceTokenAddress, destinationTokenAddress, sourceAmount and slippage + */ +export function setQuotesNavigationsParams(sourceTokenAddress, destinationTokenAddress, sourceAmount, slippage) { + return { + sourceTokenAddress, + destinationTokenAddress, + sourceAmount, + slippage + }; +} + +/** + * Gets required parameters for Swaps Quotes View + * @param {object} navigation React-navigation's navigation prop + * @return {object} Object containing sourceTokenAddress, destinationTokenAddress, sourceAmount and slippage + */ +export function getQuotesNavigationsParams(navigation) { + const slippage = navigation.getParam('slippage', 1); + const sourceTokenAddress = navigation.getParam('sourceTokenAddress', ''); + const destinationTokenAddress = navigation.getParam('destinationTokenAddress', ''); + const sourceAmount = navigation.getParam('sourceAmount'); + + return { + sourceTokenAddress, + destinationTokenAddress, + sourceAmount, + slippage + }; +} + +/** + * Returns object required to startFetchAndSetQuotes + * @param {object} options + * @param {string|number} options.slippage + * @param {object} options.sourceToken sourceToken object from tokens API + * @param {object} options.destinationToken destinationToken object from tokens API + * @param {string} sourceAmount Amount in minimal token units of sourceToken to be swapped + * @param {string} fromAddress Current address attempting to swap + */ +export function getFetchParams({ + slippage = 1, + sourceToken, + destinationToken, + sourceAmount, + walletAddress, + destinationTokenConversionRate +}) { + return { + slippage, + sourceToken: sourceToken.address, + destinationToken: destinationToken.address, + sourceAmount, + walletAddress, + balanceError: undefined, + metaData: { + sourceTokenInfo: sourceToken, + destinationTokenInfo: destinationToken, + accountBalance: '0x0', + destinationTokenConversionRate + } + }; +} + +export function useRatio(numeratorAmount, numeratorDecimals, denominatorAmount, denominatorDecimals) { + const ratio = useMemo( + () => + new BigNumber(numeratorAmount) + .dividedBy(denominatorAmount) + .multipliedBy(new BigNumber(10).pow(denominatorDecimals - numeratorDecimals)), + [denominatorAmount, denominatorDecimals, numeratorAmount, numeratorDecimals] + ); + + return ratio; +} + +export function getErrorMessage(errorKey) { + const { SwapsError } = swapsUtils; + const errorAction = + errorKey === SwapsError.QUOTES_EXPIRED_ERROR ? strings('swaps.get_new_quotes') : strings('swaps.try_again'); + switch (errorKey) { + case SwapsError.QUOTES_EXPIRED_ERROR: { + return [strings('swaps.quotes_timeout'), strings('swaps.request_new_quotes'), errorAction]; + } + case SwapsError.QUOTES_NOT_AVAILABLE_ERROR: { + return [strings('swaps.quotes_not_available'), strings('swaps.try_adjusting'), errorAction]; + } + default: { + return [ + strings('swaps.error_fetching_quote'), + strings('swaps.unexpected_error', { error: errorKey || 'error-not-provided' }), + errorAction + ]; + } + } +} diff --git a/app/components/UI/Swaps/utils/useBalance.js b/app/components/UI/Swaps/utils/useBalance.js new file mode 100644 index 00000000000..9e1900eb70f --- /dev/null +++ b/app/components/UI/Swaps/utils/useBalance.js @@ -0,0 +1,30 @@ +import { toChecksumAddress } from '@walletconnect/utils'; +import { useMemo } from 'react'; +import { renderFromTokenMinimalUnit, renderFromWei } from '../../../../util/number'; + +function useBalance(accounts, balances, selectedAddress, sourceToken, { asUnits = false } = {}) { + const balance = useMemo(() => { + if (!sourceToken) { + return null; + } + if (sourceToken.symbol === 'ETH') { + if (asUnits) { + return (accounts[selectedAddress] && accounts[selectedAddress].balance) || 0; + } + return renderFromWei(accounts[selectedAddress] && accounts[selectedAddress].balance); + } + const tokenAddress = toChecksumAddress(sourceToken.address); + + if (tokenAddress in balances) { + if (asUnits) { + return balances[tokenAddress]; + } + return renderFromTokenMinimalUnit(balances[tokenAddress], sourceToken.decimals); + } + return 0; + }, [accounts, asUnits, balances, selectedAddress, sourceToken]); + + return balance; +} + +export default useBalance; diff --git a/app/components/UI/Swaps/utils/useGasPrice.js b/app/components/UI/Swaps/utils/useGasPrice.js new file mode 100644 index 00000000000..67981d2286a --- /dev/null +++ b/app/components/UI/Swaps/utils/useGasPrice.js @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useState } from 'react'; +import { fetchBasicGasEstimates } from '../../../../util/custom-gas'; +import Logger from '../../../../util/Logger'; + +function useGasPrice() { + const [price, setPrice] = useState(null); + + const getPrices = useCallback(async () => { + try { + const basicGasEstimates = await fetchBasicGasEstimates(); + setPrice(basicGasEstimates); + } catch (error) { + Logger.log('Swaps: Error while trying to get gas estimates', error); + } + }, []); + + useEffect(() => { + getPrices(); + }, [getPrices]); + + const updatePrice = useCallback(() => getPrices(), [getPrices]); + + return [price, updatePrice]; +} + +export default useGasPrice; diff --git a/app/components/Views/SendFlow/SendTo/index.js b/app/components/Views/SendFlow/SendTo/index.js index 7dd5cc07883..480add1c032 100644 --- a/app/components/Views/SendFlow/SendTo/index.js +++ b/app/components/Views/SendFlow/SendTo/index.js @@ -24,6 +24,7 @@ import { util } from '@metamask/controllers'; import Analytics from '../../../../core/Analytics'; import { ANALYTICS_EVENT_OPTS } from '../../../../util/analytics'; import { allowedToBuy } from '../../../UI/FiatOrders'; +import NetworkList from '../../../../util/networks'; const { hexToBN } = util; const styles = StyleSheet.create({ @@ -281,7 +282,7 @@ class SendFlow extends PureComponent { onToSelectedAddressChange = async toSelectedAddress => { const { AssetsContractController } = Engine.context; - const { addressBook, network, identities } = this.props; + const { addressBook, network, identities, providerType } = this.props; const networkAddressBook = addressBook[network] || {}; let addressError, toAddressName, toEnsName, errorContinue, isOnlyWarning; let [addToAddressToAddressBook, toSelectedAddressReady] = [false, false]; @@ -304,21 +305,24 @@ class SendFlow extends PureComponent { addToAddressToAddressBook = true; } - // Check if it's token contract address - try { - const symbol = await AssetsContractController.getAssetSymbol(toSelectedAddress); - if (symbol) { - addressError = ( - - {strings('transaction.tokenContractAddressWarning_1')} - {strings('transaction.tokenContractAddressWarning_2')} - {strings('transaction.tokenContractAddressWarning_3')} - - ); - errorContinue = true; + // Check if it's token contract address on mainnet + const networkId = NetworkList[providerType].networkId; + if (networkId === 1) { + try { + const symbol = await AssetsContractController.getAssetSymbol(toSelectedAddress); + if (symbol) { + addressError = ( + + {strings('transaction.tokenContractAddressWarning_1')} + {strings('transaction.tokenContractAddressWarning_2')} + {strings('transaction.tokenContractAddressWarning_3')} + + ); + errorContinue = true; + } + } catch (e) { + // Not a token address } - } catch (e) { - // Not a token address } /** diff --git a/app/core/AppConstants.js b/app/core/AppConstants.js index d86b3e912a8..37c5a48bdba 100644 --- a/app/core/AppConstants.js +++ b/app/core/AppConstants.js @@ -57,7 +57,10 @@ export default { ORIGIN_QR_CODE: 'qr-code' }, SWAPS: { - ACTIVE: false + ACTIVE: false, + POLL_COUNT_LIMIT: 3, + POLLING_INTERVAL: 2 * 60 * 1000, + DEFAULT_SLIPPAGE: 3 }, MAX_SAFE_CHAIN_ID: 4503599627370476 }; diff --git a/app/core/Engine.js b/app/core/Engine.js index 5abf8764380..727025b28d0 100644 --- a/app/core/Engine.js +++ b/app/core/Engine.js @@ -14,11 +14,11 @@ import { PreferencesController, TokenBalancesController, TokenRatesController, - TransactionController, + // TransactionController, TypedMessageManager } from '@metamask/controllers'; -import { SwapsController } from '@estebanmino/controllers'; +import { SwapsController, TransactionController } from '@estebanmino/controllers'; import AsyncStorage from '@react-native-community/async-storage'; @@ -61,6 +61,7 @@ class Engine { nativeCurrency: 'eth', currentCurrency: 'usd' }; + this.datamodel = new ComposableController( [ new KeyringController({ encryptor }, initialState.KeyringController), @@ -126,7 +127,8 @@ class Engine { AssetsController: assets, KeyringController: keyring, NetworkController: network, - TransactionController: transaction + TransactionController: transaction, + PreferencesController: preferences } = this.datamodel.context; assets.setApiKey(process.env.MM_OPENSEA_KEY); @@ -135,6 +137,19 @@ class Engine { network.subscribe(this.refreshNetwork); this.configureControllersOnNetworkChange(); Engine.instance = this; + + preferences.addToFrequentRpcList( + 'http://ganache-testnet.airswap-dev.codefi.network/', + 1337, + 'ETH', + 'Swaps Test Network' + ); + network.setRpcTarget( + 'http://ganache-testnet.airswap-dev.codefi.network/', + 1337, + 'ETH', + 'Swaps Test Network' + ); } return Engine.instance; } @@ -145,13 +160,19 @@ class Engine { AssetsContractController, AssetsDetectionController, NetworkController: { provider }, - TransactionController + TransactionController, + SwapsController } = this.datamodel.context; provider.sendAsync = provider.sendAsync.bind(provider); AccountTrackerController.configure({ provider }); AccountTrackerController.refresh(); AssetsContractController.configure({ provider }); + SwapsController.configure({ + provider, + pollCountLimit: AppConstants.SWAPS.POLL_COUNT_LIMIT, + quotePollingInterval: AppConstants.SWAPS.POLLING_INTERVAL + }); TransactionController.configure({ provider }); TransactionController.hub.emit('networkChange'); AssetsDetectionController.detectAssets(); diff --git a/app/images/piggybank.png b/app/images/piggybank.png new file mode 100644 index 00000000000..8cd99c0d27d Binary files /dev/null and b/app/images/piggybank.png differ diff --git a/app/images/sliderbutton-bg.png b/app/images/sliderbutton-bg.png new file mode 100644 index 00000000000..f061a990385 Binary files /dev/null and b/app/images/sliderbutton-bg.png differ diff --git a/app/images/sliderbutton-shine.png b/app/images/sliderbutton-shine.png new file mode 100644 index 00000000000..428ec56e0f4 Binary files /dev/null and b/app/images/sliderbutton-shine.png differ diff --git a/app/images/slippage-slider-bg.png b/app/images/slippage-slider-bg.png new file mode 100644 index 00000000000..cbbca907ba1 Binary files /dev/null and b/app/images/slippage-slider-bg.png differ diff --git a/app/styles/common.js b/app/styles/common.js index 82e5ecc767c..d6431dd19e9 100644 --- a/app/styles/common.js +++ b/app/styles/common.js @@ -32,7 +32,9 @@ export const colors = { blue: '#037dd6', blue000: '#eaf6ff', blue200: '#75C4FD', + blue500: '#1097FB', blue600: '#0260A4', + blue700: '#0074C8', green600: '#1e7e34', green500: '#28a745', green400: '#28A745', @@ -48,6 +50,7 @@ export const colors = { orange300: '#faa66c', orange000: '#fef5ef', spinnerColor: '#037DD6', + success: '#219E37', dimmed: '#00000080', transparent: 'transparent', lightOverlay: 'rgba(0,0,0,.2)', diff --git a/app/util/transactions.js b/app/util/transactions.js index 1c89ada77f8..051dcd4c061 100644 --- a/app/util/transactions.js +++ b/app/util/transactions.js @@ -138,6 +138,14 @@ export function generateApproveData(opts) { ); } +export function decodeApproveData(data) { + console.log('decodeApproveData', data); + return { + spenderAddress: addHexPrefix(data.substr(34, 40)), + encodedAmount: data.substr(74, 138) + }; +} + /** * Decode transfer data for specified method data * diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e8e6dc01c61..a9b629d2712 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -346,8 +346,8 @@ PODS: - ReactCommon/callinvoker (= 0.62.2) - ReactNativePayments (1.5.0): - React - - RNCAsyncStorage (1.9.0): - - React + - RNCAsyncStorage (1.12.1): + - React-Core - RNCCheckbox (0.4.2): - React - RNCClipboard (1.2.2): @@ -661,7 +661,7 @@ SPEC CHECKSUMS: React-RCTVibration: 4356114dbcba4ce66991096e51a66e61eda51256 ReactCommon: ed4e11d27609d571e7eee8b65548efc191116eb3 ReactNativePayments: a4e3ac915256a4e759c8a04338b558494a63a0f5 - RNCAsyncStorage: 453cd7c335ec9ba3b877e27d02238956b76f3268 + RNCAsyncStorage: b03032fdbdb725bea0bd9e5ec5a7272865ae7398 RNCCheckbox: 357578d3b42652c78ee9a1bb9bcfc3195af6e161 RNCClipboard: 8148e21ac347c51fd6cd4b683389094c216bb543 RNDeviceInfo: 88099e84466f053bae3f34c307b506738b2b6946 diff --git a/locales/en.json b/locales/en.json index 0241bd52f63..8883a4bef52 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1391,11 +1391,58 @@ "select_a_token": "Select a token", "search_token": "Search for a token", "no_tokens_result": "No tokens match “{{searchString}}”", - "available_to_swap": "{{asset}} available to swap", + "available_to_swap": "{{asset}} available to swap.", + "use_max": "Use max", "not_enough": "Not enough {{symbol}} to complete this swap", - "max_slippage": "Max slippage {{slippage}}", + "max_slippage": "Max Slippage", + "max_slippage_amount": "Max slippage {{slippage}}", + "slippage_info": "If the rate changes between the time your order is placed and confirmed it’s called “slippage”. Your Swap will automatically cancel if slippage exceeds your “max slippage” setting.", + "slippage_warning": "Make sure you know what you’re doing!", "allows_up_to_decimals": "{{symbol}} allows up to {{decimals}} decimals", - "get_quotes": "Get quotes" + "get_quotes": "Get quotes", + "fetching_new_quotes": "Fetching new quotes...", + "you_need": "You need", + "more_to_complete": "more to complete this swap.", + "new_quotes_in": "New quotes in", + "quotes_expire_in": "Quotes expire in", + "saving": "Saving", + "using_best_quote": "Using the best quote", + "view_details": "View details", + "estimated_gas_fee": "Estimated gas fee", + "max_gas_fee": "Max gas fee", + "edit": "Edit", + "quotes_include_fee": "Quotes include a {{fee}}% MetaMask fee", + "tap_to_swap": "Tap to Swap", + "swipe_to_swap": "Swipe to swap", + "swipe_to": "Swipe to", + "swap": "swap", + "completed_swap": "Swap!", + "metamask_swap_fee": "MetaMask Swap fee", + "fee_text": { + "get_the": "Get the", + "best_price": "best price", + "from_the": "from the", + "top_liquidity": "top liquidity sources every time.", + "fee_is_applied": "A fee of {{fee}} is automatically factored into each quote, which supports ongoing development to make MetaMask even better." + }, + "enable": { + "this_will": "This will", + "enable_asset": "enable {{asset}}", + "for_swapping": "for swapping", + "edit_limit": "Edit limit" + }, + "quotes_overview": "Quotes overview", + "receiving": "Receiving", + "overall_value": "Overall value", + "best": "Best", + "quotes_timeout": "Quotes timeout", + "request_new_quotes": "Please request new quotes to get the latest best rate.", + "quotes_not_available": "Quotes not available", + "try_adjusting": "Try adjusting the amount and try again.", + "error_fetching_quote": "Error fetching quote", + "unexpected_error": "There has been an unexpected error, please request new quotes to get the latest best rate. (error: {{error}})", + "get_new_quotes": "Get new quotes", + "try_again": "Try again" }, "protect_wallet_modal": { "title": "Protect your wallet", diff --git a/locales/es.json b/locales/es.json index 5f940b3071c..14aa2e70ba3 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1254,11 +1254,58 @@ "select_a_token": "Selecciona un token", "search_token": "Buscar un token", "no_tokens_result": "No se encontraron tokens para “{{searchString}}”", - "available_to_swap": "{{asset}} disponible para convertir", + "available_to_swap": "{{asset}} disponible para convertir.", + "use_max": "Usar máximo", "not_enough": "Insuficiente {{symbol}} para esta conversión", - "max_slippage": "Deslizamiento máx. {{slippage}}", + "max_slippage": "Deslizamiento Máximo", + "max_slippage_amount": "Deslizamiento máx. {{slippage}}", + "slippage_info": "Se le llama “deslizamiento” al cambio de tarifa en el tiempo entre que haces y se confirma la orden. Tu conversión se cancelará automáticamente si el deslizamiento excede este máximo.", + "slippage_warning": "¡Asegúrate saber lo que haces!", "allows_up_to_decimals": "{{symbol}} soporta hasta {{decimals}} decimales", - "get_quotes": "Cotizar" + "get_quotes": "Cotizar", + "fetching_new_quotes": "Obteniendo cotización...", + "you_need": "Necesitas", + "more_to_complete": "más para completar este swap.", + "new_quotes_in": "Nueva cotización en", + "quotes_expire_in": "Cotización expira en", + "saving": "Ahorrando", + "using_best_quote": "Usando mejor cotización", + "view_details": "Ver detalles", + "estimated_gas_fee": "Tarifa de transacción estimada", + "max_gas_fee": "Tarifa de transacción máxima", + "edit": "Editar", + "quotes_include_fee": "Cotizaciones incluyen {{fee}}% de comisión de MetaMask", + "tap_to_swap": "Presiona para hacer Swap", + "swipe_to_swap": "Desliza para hacer swap", + "swipe_to": "Desliza para", + "swap": "hacer swap", + "completed_swap": "Swap!", + "metamask_swap_fee": "Comisión MetaMask Swap", + "fee_text": { + "get_the": "Obtén el", + "best_price": "mejor precio", + "from_the": "desde los", + "top_liquidity": "mejores recursos de liquidez, siempre.", + "fee_is_applied": "Una comisión de {{fee}} es aplicada automáticamente en cada cotización, lo que apoya el desarrollo continuo para hacer MetaMask aún mejor." + }, + "enable": { + "this_will": "Esto", + "enable_asset": "permitirá {{asset}}", + "for_swapping": "para ser convertido", + "edit_limit": "Editar límite" + }, + "quotes_overview": "Cotizaciones", + "receiving": "Recibiendo", + "overall_value": "Valor total", + "best": "Mejor", + "quotes_timeout": "Se acabó el tiempo", + "request_new_quotes": "Por favor cotiza de nuevo para obtener la mejor tarifa.", + "quotes_not_available": "Cotización no disponible", + "try_adjusting": "Ajusta los montos e intenta de nuevo.", + "error_fetching_quote": "Error al cotizar", + "unexpected_error": "Ha ocurrido un error, por favor cotiza de nuevo para obtener la mejor tarifa. (error: {{error}})", + "get_new_quotes": "Cotizar nuevamente", + "try_again": "Intentar de nuevo" }, "protect_wallet_modal": { "title": "Protege tu billetera", diff --git a/package.json b/package.json index dee21d686d3..e61da155750 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "react-native-level-fs/**/bl": "^0.9.5" }, "dependencies": { - "@estebanmino/controllers": "3.2.4", + "@estebanmino/controllers": "3.2.38", "@exodus/react-native-payments": "https://github.com/wachunei/react-native-payments.git#package-json-hack", "@metamask/contract-metadata": "^1.19.0", "@metamask/controllers": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 84b1a91c12c..a0e6ccbf6a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -735,30 +735,31 @@ dependencies: "@types/hammerjs" "^2.0.36" -"@estebanmino/controllers@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@estebanmino/controllers/-/controllers-3.2.4.tgz#c6e4ed2fb130179553f4efdcfd7d7e485e0f55b2" - integrity sha512-gBhELpaUlpVZ0eoVjRl8bMedBX9ndRngM1pP6ChzCPupahZ6pInabNkdIG5w2XDW9c9Z3LW2CxrhePOE1jWmJQ== +"@estebanmino/controllers@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@estebanmino/controllers/-/controllers-3.2.38.tgz#7c1122bfb7f7ab540257a0b032ea045184f0c0e7" + integrity sha512-WQ5R2DQ+WMeEBNHwBIBbgmJH2wVU7ykez4aVseSFJWadTfLN+HV7rJCL/i78qf1mg3/wofm32QAd6h0oFUGOZw== dependencies: + "@metamask/contract-metadata" "^1.19.0" + abort-controller "^3.0.0" await-semaphore "^0.1.3" bignumber.js "^9.0.1" - eth-contract-metadata "^1.11.0" eth-ens-namehash "^2.0.8" eth-json-rpc-infura "^5.1.0" - eth-keyring-controller "^5.6.1" + eth-keyring-controller "^6.1.0" eth-method-registry "1.1.0" eth-phishing-detect "^1.1.13" eth-query "^2.1.2" - eth-rpc-errors "^3.0.0" - eth-sig-util "^2.3.0" + eth-rpc-errors "^4.0.0" + eth-sig-util "^3.0.0" ethereumjs-util "^6.1.0" - ethereumjs-wallet "0.6.0" + ethereumjs-wallet "^0.6.4" ethjs-query "^0.3.8" human-standard-collectible-abi "^1.0.2" human-standard-token-abi "^2.0.0" isomorphic-fetch "^3.0.0" jsonschema "^1.2.4" - percentile "^1.2.1" + nanoid "^3.1.12" single-call-balance-checker-abi "^1.0.0" uuid "^3.3.2" web3 "^0.20.7" @@ -2096,11 +2097,6 @@ aes-js@3.0.0: resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.0.0.tgz#e21df10ad6c2053295bcbb8dab40b09dbea87e4d" integrity sha1-4h3xCtbCBTKVvLuNq0Cwnb6ofk0= -aes-js@^0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-0.2.4.tgz#94b881ab717286d015fa219e08fb66709dda5a3d" - integrity sha1-lLiBq3FyhtAV+iGeCPtmcJ3aWj0= - aes-js@^3.1.1, aes-js@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a" @@ -2721,11 +2717,6 @@ base-64@0.1.0, base-64@^0.1.0: resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" integrity sha1-eAqZyE59YAJgNhURxId2E78k9rs= -base-x@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/base-x/-/base-x-1.1.0.tgz#42d3d717474f9ea02207f6d1aa1f426913eeb7ac" - integrity sha1-QtPXF0dPnqAiB/bRqh9CaRPut6w= - base-x@^3.0.2, base-x@^3.0.8: version "3.0.8" resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" @@ -3003,18 +2994,6 @@ browserify-unibabel@^3.0.0: resolved "https://registry.yarnpkg.com/browserify-unibabel/-/browserify-unibabel-3.0.0.tgz#5a6b8f0f704ce388d3927df47337e25830f71dda" integrity sha1-WmuPD3BM44jTkn30czfiWDD3Hdo= -bs58@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/bs58/-/bs58-2.0.1.tgz#55908d58f1982aba2008fa1bed8f91998a29bf8d" - integrity sha1-VZCNWPGYKrogCPob7Y+RmYopv40= - -bs58@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/bs58/-/bs58-3.1.0.tgz#d4c26388bf4804cac714141b1945aa47e5eb248e" - integrity sha1-1MJjiL9IBMrHFBQbGUWqR+XrJI4= - dependencies: - base-x "^1.1.0" - bs58@^4.0.0, bs58@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" @@ -3022,14 +3001,6 @@ bs58@^4.0.0, bs58@^4.0.1: dependencies: base-x "^3.0.2" -bs58check@^1.0.8: - version "1.3.4" - resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-1.3.4.tgz#c52540073749117714fa042c3047eb8f9151cbf8" - integrity sha1-xSVABzdJEXcU+gQsMEfrj5FRy/g= - dependencies: - bs58 "^3.1.0" - create-hash "^1.1.0" - bs58check@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" @@ -3416,14 +3387,6 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= -coinstring@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/coinstring/-/coinstring-2.3.0.tgz#cdb63363a961502404a25afb82c2e26d5ff627a4" - integrity sha1-zbYzY6lhUCQEolr7gsLibV/2J6Q= - dependencies: - bs58 "^2.0.1" - create-hash "^1.1.1" - collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -3692,7 +3655,7 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" -create-hash@^1.1.0, create-hash@^1.1.1, create-hash@^1.1.2, create-hash@^1.2.0: +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== @@ -4919,11 +4882,6 @@ eth-block-tracker@^4.4.2: pify "^3.0.0" safe-event-emitter "^1.0.1" -eth-contract-metadata@^1.11.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/eth-contract-metadata/-/eth-contract-metadata-1.17.0.tgz#96d4b056ac9a7175eeba091dbabd0713cfd4c703" - integrity sha512-vlw4OiW3+9J3kJfEtPCyiSW9fhdWTqrAhXcvdMY2CevGxbhvOd5Lz59DeWerSTV3IoSXttghDurPA76dAeTV+A== - eth-ens-namehash@2.0.8, eth-ens-namehash@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/eth-ens-namehash/-/eth-ens-namehash-2.0.8.tgz#229ac46eca86d52e0c991e7cb2aef83ff0f68bcf" @@ -5059,21 +5017,6 @@ eth-json-rpc-middleware@^6.0.0: pify "^3.0.0" safe-event-emitter "^1.0.1" -eth-keyring-controller@^5.6.1: - version "5.6.1" - resolved "https://registry.yarnpkg.com/eth-keyring-controller/-/eth-keyring-controller-5.6.1.tgz#7b7268400704c8f5ce98a055910341177dd207ca" - integrity sha512-sxJ87bJg7PvvPzj1sY1jJYHQL1HVUhh84Q/a4QPrcnzAAng1yibvvUfww0pCez4XJfHuMkJvUxfF8eAusJM8fQ== - dependencies: - bip39 "^2.4.0" - bluebird "^3.5.0" - browser-passworder "^2.0.3" - eth-hd-keyring "^3.5.0" - eth-sig-util "^1.4.0" - eth-simple-keyring "^3.5.0" - ethereumjs-util "^5.1.2" - loglevel "^1.5.0" - obs-store "^4.0.3" - eth-keyring-controller@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/eth-keyring-controller/-/eth-keyring-controller-6.1.0.tgz#dc9313d0b793e085dc1badf84dd4f5e3004e127e" @@ -5133,7 +5076,7 @@ eth-sig-util@^1.4.0, eth-sig-util@^1.4.2: ethereumjs-abi "git+https://github.com/ethereumjs/ethereumjs-abi.git" ethereumjs-util "^5.1.1" -eth-sig-util@^2.3.0, eth-sig-util@^2.4.4, eth-sig-util@^2.5.0: +eth-sig-util@^2.4.4, eth-sig-util@^2.5.0: version "2.5.3" resolved "https://registry.yarnpkg.com/eth-sig-util/-/eth-sig-util-2.5.3.tgz#6938308b38226e0b3085435474900b03036abcbe" integrity sha512-KpXbCKmmBUNUTGh9MRKmNkIPietfhzBqqYqysDavLseIiMUGl95k6UcPEkALAZlj41e9E6yioYXc1PC333RKqw== @@ -5312,17 +5255,6 @@ ethereumjs-util@^4.3.0: rlp "^2.0.0" secp256k1 "^3.0.1" -ethereumjs-util@^4.4.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-4.5.1.tgz#f4bf9b3b515a484e3cc8781d61d9d980f7c83bd0" - integrity sha512-WrckOZ7uBnei4+AKimpuF1B3Fv25OmoRgmYCpGsP7u8PFxXAmAgiJSYT2kRWnt6fVIlKaQlZvuwXp7PIrmn3/w== - dependencies: - bn.js "^4.8.0" - create-hash "^1.1.2" - elliptic "^6.5.2" - ethereum-cryptography "^0.1.3" - rlp "^2.0.0" - ethereumjs-util@^5.0.0, ethereumjs-util@^5.1.1, ethereumjs-util@^5.1.2, ethereumjs-util@^5.1.5: version "5.2.0" resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-5.2.0.tgz#3e0c0d1741471acf1036052d048623dee54ad642" @@ -5366,19 +5298,6 @@ ethereumjs-vm@^2.3.4, ethereumjs-vm@^2.6.0: rustbn.js "~0.2.0" safe-buffer "^5.1.1" -ethereumjs-wallet@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/ethereumjs-wallet/-/ethereumjs-wallet-0.6.0.tgz#82763b1697ee7a796be7155da9dfb49b2f98cfdb" - integrity sha1-gnY7Fpfuenlr5xVdqd+0my+Yz9s= - dependencies: - aes-js "^0.2.3" - bs58check "^1.0.8" - ethereumjs-util "^4.4.0" - hdkey "^0.7.0" - scrypt.js "^0.2.0" - utf8 "^2.1.1" - uuid "^2.0.1" - ethereumjs-wallet@^0.6.0: version "0.6.3" resolved "https://registry.yarnpkg.com/ethereumjs-wallet/-/ethereumjs-wallet-0.6.3.tgz#b0eae6f327637c2aeb9ccb9047b982ac542e6ab1" @@ -6536,14 +6455,6 @@ hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: inherits "^2.0.3" minimalistic-assert "^1.0.1" -hdkey@^0.7.0: - version "0.7.1" - resolved "https://registry.yarnpkg.com/hdkey/-/hdkey-0.7.1.tgz#caee4be81aa77921e909b8d228dd0f29acaee632" - integrity sha1-yu5L6BqneSHpCbjSKN0PKayu5jI= - dependencies: - coinstring "^2.0.0" - secp256k1 "^3.0.1" - hdkey@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/hdkey/-/hdkey-1.1.2.tgz#c60f9cf6f90fbf24a8a52ea06893f36a0108cd3e" @@ -10118,11 +10029,6 @@ pbkdf2@^3.0.3, pbkdf2@^3.0.9: safe-buffer "^5.0.1" sha.js "^2.4.8" -percentile@^1.2.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/percentile/-/percentile-1.4.0.tgz#84e03f1f37722ddcc1f527d5367743b7bb30f336" - integrity sha512-W/Q9I9nUm9Z7JvVHWQ0zRfUUdSu3NVLeiECBCrWqxdMKAOMcUBST0RhAtlvN1itLnR8HI/LSDD96clw3RenzZQ== - performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -11759,14 +11665,6 @@ scrypt-js@3.0.1, scrypt-js@^3.0.0: resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== -scrypt.js@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/scrypt.js/-/scrypt.js-0.2.1.tgz#cc3f751933d6bac7a4bedf5301d7596e8146cdcd" - integrity sha512-XMoqxwABdotuW+l+qACmJ/h0kVSCgMPZXpbncA/zyBO90z/NnDISzVw+xJ4tUY+X/Hh0EFT269OYHm26VCPgmA== - dependencies: - scrypt "^6.0.2" - scryptsy "^1.2.1" - scrypt.js@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/scrypt.js/-/scrypt.js-0.3.0.tgz#6c62d61728ad533c8c376a2e5e3e86d41a95c4c0" @@ -13108,11 +13006,6 @@ uuid@3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== -uuid@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" - integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho= - uuid@^3.0.1, uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"