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"