1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Swaps: Improve hardware wallet UX (#10987)

This commit is contained in:
Daniel 2021-05-06 07:14:42 -07:00 committed by Dan Miller
parent 4016bb535b
commit 92a79904f7
14 changed files with 359 additions and 3 deletions

View File

@ -1804,6 +1804,10 @@
"swapAggregator": {
"message": "Aggregator"
},
"swapAllowSwappingOf": {
"message": "Allow swapping of $1",
"description": "Shows a user that they need to allow a token for swapping on their hardware wallet"
},
"swapAmountReceived": {
"message": "Guaranteed amount"
},
@ -1829,6 +1833,9 @@
"message": "Checking $1",
"description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap."
},
"swapConfirmWithHwWallet": {
"message": "Confirm with your hardware wallet"
},
"swapCustom": {
"message": "custom"
},
@ -1874,6 +1881,13 @@
"swapFinalizing": {
"message": "Finalizing..."
},
"swapFromTo": {
"message": "The swap of $1 to $2",
"description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token"
},
"swapGasFeesSplit": {
"message": "Gas fees on the previous screen are split between these two transactions."
},
"swapHighSlippageWarning": {
"message": "Slippage amount is very high."
},
@ -2015,6 +2029,9 @@
"swapThisWillAllowApprove": {
"message": "This will allow $1 to be swapped."
},
"swapToConfirmWithHwWallet": {
"message": "to confirm with your hardware wallet"
},
"swapTokenAvailable": {
"message": "Your $1 has been added to your account.",
"description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol."
@ -2044,6 +2061,9 @@
"swapTransactionComplete": {
"message": "Transaction complete"
},
"swapTwoTransactions": {
"message": "2 transactions"
},
"swapUnknown": {
"message": "Unknown"
},

View File

@ -26,6 +26,7 @@ import {
cancelTx,
} from '../../store/actions';
import {
AWAITING_SIGNATURES_ROUTE,
AWAITING_SWAP_ROUTE,
BUILD_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE,
@ -52,6 +53,7 @@ import {
getUSDConversionRate,
getSwapsDefaultToken,
getCurrentChainId,
isHardwareWallet,
} from '../../selectors';
import {
ERROR_FETCHING_QUOTES,
@ -73,6 +75,7 @@ export const FALLBACK_GAS_MULTIPLIER = 1.5;
const initialState = {
aggregatorMetadata: null,
approveTxId: null,
tradeTxId: null,
balanceError: false,
fetchingQuotes: false,
fromToken: null,
@ -95,6 +98,7 @@ const slice = createSlice({
clearSwapsState: () => initialState,
navigatedBackToBuildQuote: (state) => {
state.approveTxId = null;
state.tradeTxId = null;
state.balanceError = false;
state.fetchingQuotes = false;
state.customGas.limit = null;
@ -585,7 +589,7 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
return async (dispatch, getState) => {
const state = getState();
const chainId = getCurrentChainId(state);
const hardwareWalletUsed = isHardwareWallet(state);
let swapsFeatureIsLive = false;
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness(chainId);
@ -605,7 +609,10 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData;
await dispatch(setBackgroundSwapRouteState('awaiting'));
await dispatch(stopPollingForQuotes());
if (!hardwareWalletUsed) {
history.push(AWAITING_SWAP_ROUTE);
}
const { fast: fastGasEstimate } = getSwapGasPriceEstimateData(state);
@ -694,6 +701,13 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
let finalApproveTxMeta;
const approveTxParams = getApproveTxParams(state);
// For hardware wallets we go to the Awaiting Signatures page first and only after a user
// completes 1 or 2 confirmations, we redirect to the Awaiting Swap page.
if (hardwareWalletUsed) {
history.push(AWAITING_SIGNATURES_ROUTE);
}
if (approveTxParams) {
const approveTxMeta = await dispatch(
addUnapprovedTransaction(
@ -765,6 +779,12 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
return;
}
// Only after a user confirms swapping on a hardware wallet (second `updateAndApproveTx` call above),
// we redirect to the Awaiting Swap page.
if (hardwareWalletUsed) {
history.push(AWAITING_SWAP_ROUTE);
}
await forceUpdateMetamaskState(dispatch);
};
};

View File

@ -32,6 +32,7 @@ const SWAPS_ROUTE = '/swaps';
const BUILD_QUOTE_ROUTE = '/swaps/build-quote';
const VIEW_QUOTE_ROUTE = '/swaps/view-quote';
const LOADING_QUOTES_ROUTE = '/swaps/loading-quotes';
const AWAITING_SIGNATURES_ROUTE = '/swaps/awaiting-signatures';
const AWAITING_SWAP_ROUTE = '/swaps/awaiting-swap';
const SWAPS_ERROR_ROUTE = '/swaps/swaps-error';
const SWAPS_MAINTENANCE_ROUTE = '/swaps/maintenance';
@ -191,6 +192,7 @@ export {
VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE,
AWAITING_SWAP_ROUTE,
AWAITING_SIGNATURES_ROUTE,
SWAPS_ERROR_ROUTE,
SWAPS_MAINTENANCE_ROUTE,
};

View File

@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AwaitingSignatures renders the component with initial props for 1 confirmation 1`] = `
<div
class="swaps-footer"
>
<div
class="swaps-footer__buttons"
>
<div
class="page-container__footer swaps-footer__custom-page-container-footer-class"
>
<footer>
<button
class="button btn-primary page-container__footer-button swaps-footer__custom-page-container-footer-button-class swaps-footer__custom-page-container-footer-button-class--single"
data-testid="page-container-footer-next"
role="button"
tabindex="0"
>
Cancel
</button>
</footer>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SwapStepIcon renders the component 1`] = `
<div>
<svg
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="7"
cy="7"
r="6.25"
stroke="#037DD6"
stroke-width="1.5"
/>
<path
d="M6.50983 5.192H5.27783L6.14183 4H7.71783V9.68H6.50983V5.192Z"
fill="#037DD6"
/>
</svg>
</div>
`;

View File

@ -0,0 +1,139 @@
import React, { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { I18nContext } from '../../../contexts/i18n';
import { useNewMetricEvent } from '../../../hooks/useMetricEvent';
import {
getFetchParams,
getApproveTxParams,
prepareToLeaveSwaps,
} from '../../../ducks/swaps/swaps';
import {
DEFAULT_ROUTE,
BUILD_QUOTE_ROUTE,
} from '../../../helpers/constants/routes';
import PulseLoader from '../../../components/ui/pulse-loader';
import Typography from '../../../components/ui/typography';
import Box from '../../../components/ui/box';
import {
BLOCK_SIZES,
COLORS,
TYPOGRAPHY,
FONT_WEIGHT,
JUSTIFY_CONTENT,
DISPLAY,
} from '../../../helpers/constants/design-system';
import SwapsFooter from '../swaps-footer';
import SwapStepIcon from './swap-step-icon';
export default function AwaitingSignatures() {
const t = useContext(I18nContext);
const history = useHistory();
const dispatch = useDispatch();
const fetchParams = useSelector(getFetchParams);
const { destinationTokenInfo, sourceTokenInfo } = fetchParams?.metaData || {};
const approveTxParams = useSelector(getApproveTxParams);
const needsTwoConfirmations = Boolean(approveTxParams);
const awaitingSignaturesEvent = useNewMetricEvent({
event: 'Awaiting Signature(s) on a HW wallet',
sensitiveProperties: {
needs_two_confirmations: needsTwoConfirmations,
token_from: sourceTokenInfo?.symbol,
token_from_amount: fetchParams?.value,
token_to: destinationTokenInfo?.symbol,
request_type: fetchParams?.balanceError ? 'Quote' : 'Order',
slippage: fetchParams?.slippage,
custom_slippage: fetchParams?.slippage === 2,
},
category: 'swaps',
});
useEffect(() => {
awaitingSignaturesEvent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const headerText = needsTwoConfirmations
? t('swapTwoTransactions')
: t('swapConfirmWithHwWallet');
return (
<div className="awaiting-signatures">
<Box
paddingLeft={8}
paddingRight={8}
height={BLOCK_SIZES.FULL}
justifyContent={JUSTIFY_CONTENT.CENTER}
display={DISPLAY.FLEX}
className="awaiting-signatures__content"
>
<Box marginTop={3} marginBottom={4}>
<PulseLoader />
</Box>
<Typography color={COLORS.BLACK} variant={TYPOGRAPHY.H3}>
{headerText}
</Typography>
{needsTwoConfirmations && (
<>
<Typography
variant={TYPOGRAPHY.Paragraph}
boxProps={{ marginTop: 2 }}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('swapToConfirmWithHwWallet')}
</Typography>
<ul className="awaiting-signatures__steps">
<li>
<SwapStepIcon stepNumber={1} />
{t('swapAllowSwappingOf', [
<Typography
tag="span"
fontWeight={FONT_WEIGHT.BOLD}
key="allowToken"
>
{destinationTokenInfo?.symbol}
</Typography>,
])}
</li>
<li>
<SwapStepIcon stepNumber={2} />
{t('swapFromTo', [
<Typography
tag="span"
fontWeight={FONT_WEIGHT.BOLD}
key="tokenFrom"
>
{sourceTokenInfo?.symbol}
</Typography>,
<Typography
tag="span"
fontWeight={FONT_WEIGHT.BOLD}
key="tokenTo"
>
{destinationTokenInfo?.symbol}
</Typography>,
])}
</li>
</ul>
<Typography variant={TYPOGRAPHY.Paragraph}>
{t('swapGasFeesSplit')}
</Typography>
</>
)}
</Box>
<SwapsFooter
onSubmit={async () => {
await dispatch(prepareToLeaveSwaps());
// Go to the default route and then to the build quote route in order to clean up
// the `inputValue` local state in `pages/swaps/index.js`
history.push(DEFAULT_ROUTE);
history.push(BUILD_QUOTE_ROUTE);
}}
submitText={t('cancel')}
hideCancel
/>
</div>
);
}

View File

@ -0,0 +1,17 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import {
renderWithProvider,
createSwapsMockStore,
} from '../../../../test/jest';
import AwaitingSignatures from '.';
describe('AwaitingSignatures', () => {
it('renders the component with initial props for 1 confirmation', () => {
const store = configureMockStore()(createSwapsMockStore());
const { getByText } = renderWithProvider(<AwaitingSignatures />, store);
expect(getByText('Confirm with your hardware wallet')).toBeInTheDocument();
expect(document.querySelector('.swaps-footer')).toMatchSnapshot();
});
});

View File

@ -0,0 +1 @@
export { default } from './awaiting-signatures';

View File

@ -0,0 +1,34 @@
.awaiting-signatures {
display: flex;
flex-flow: column;
align-items: center;
flex: 1;
width: 100%;
&__content {
flex-flow: column;
}
div {
text-align: center;
display: flex;
justify-content: center;
}
&__steps {
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 16px auto 12px auto;
li {
margin-bottom: 4px;
display: flex;
align-items: center;
svg {
margin-right: 4px;
}
}
}
}

View File

@ -0,0 +1,40 @@
import React from 'react';
export default function SwapStepIcon({ stepNumber = 1 }) {
switch (stepNumber) {
case 1:
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="7" cy="7" r="6.25" stroke="#037DD6" strokeWidth="1.5" />
<path
d="M6.50983 5.192H5.27783L6.14183 4H7.71783V9.68H6.50983V5.192Z"
fill="#037DD6"
/>
</svg>
);
case 2:
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="7" cy="7" r="6.25" stroke="#037DD6" strokeWidth="1.5" />
<path
d="M8.92 9.776H5V9.368C5 9.048 5.056 8.77067 5.168 8.536C5.28 8.296 5.42133 8.08533 5.592 7.904C5.768 7.71733 5.96267 7.54933 6.176 7.4C6.39467 7.25067 6.608 7.10133 6.816 6.952C6.928 6.872 7.03467 6.78933 7.136 6.704C7.24267 6.61867 7.33333 6.53067 7.408 6.44C7.488 6.34933 7.552 6.256 7.6 6.16C7.648 6.064 7.672 5.96533 7.672 5.864C7.672 5.67733 7.616 5.52 7.504 5.392C7.39733 5.25867 7.22933 5.192 7 5.192C6.88267 5.192 6.776 5.21333 6.68 5.256C6.584 5.29333 6.50133 5.344 6.432 5.408C6.368 5.472 6.31733 5.54667 6.28 5.632C6.248 5.71733 6.232 5.808 6.232 5.904H5.024C5.024 5.62667 5.07467 5.37067 5.176 5.136C5.27733 4.90133 5.41867 4.70133 5.6 4.536C5.78133 4.36533 5.99467 4.23467 6.24 4.144C6.48533 4.048 6.752 4 7.04 4C7.28 4 7.50933 4.03733 7.728 4.112C7.952 4.18667 8.14933 4.29867 8.32 4.448C8.49067 4.59733 8.62667 4.784 8.728 5.008C8.82933 5.22667 8.88 5.48267 8.88 5.776C8.88 6.032 8.85067 6.25867 8.792 6.456C8.73333 6.648 8.65067 6.824 8.544 6.984C8.44267 7.13867 8.32 7.28 8.176 7.408C8.032 7.536 7.87733 7.66133 7.712 7.784C7.64267 7.832 7.55733 7.888 7.456 7.952C7.36 8.016 7.26133 8.08267 7.16 8.152C7.064 8.22133 6.97333 8.29333 6.888 8.368C6.80267 8.44267 6.74133 8.51467 6.704 8.584H8.92V9.776Z"
fill="#037DD6"
/>
</svg>
);
default:
return undefined; // Don't return any SVG if a step number is not supported.
}
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import { renderWithProvider } from '../../../../test/jest';
import SwapStepIcon from './swap-step-icon';
describe('SwapStepIcon', () => {
it('renders the component', () => {
const { container } = renderWithProvider(<SwapStepIcon />);
expect(container).toMatchSnapshot();
});
});

View File

@ -33,6 +33,7 @@ import {
fetchSwapsLiveness,
} from '../../ducks/swaps/swaps';
import {
AWAITING_SIGNATURES_ROUTE,
AWAITING_SWAP_ROUTE,
BUILD_QUOTE_ROUTE,
VIEW_QUOTE_ROUTE,
@ -66,6 +67,7 @@ import {
getSwapsTokensReceivedFromTxMeta,
fetchAggregatorMetadata,
} from './swaps.util';
import AwaitingSignatures from './awaiting-signatures';
import AwaitingSwap from './awaiting-swap';
import LoadingQuote from './loading-swaps-quotes';
import BuildQuote from './build-quote';
@ -78,6 +80,7 @@ export default function Swap() {
const { pathname } = useLocation();
const isAwaitingSwapRoute = pathname === AWAITING_SWAP_ROUTE;
const isAwaitingSignaturesRoute = pathname === AWAITING_SIGNATURES_ROUTE;
const isSwapsErrorRoute = pathname === SWAPS_ERROR_ROUTE;
const isLoadingQuotesRoute = pathname === LOADING_QUOTES_ROUTE;
@ -243,7 +246,7 @@ export default function Swap() {
<div className="swaps__container">
<div className="swaps__header">
<div className="swaps__title">{t('swap')}</div>
{!isAwaitingSwapRoute && (
{!isAwaitingSwapRoute && !isAwaitingSignaturesRoute && (
<div
className="swaps__header-cancel"
onClick={async () => {
@ -372,6 +375,13 @@ export default function Swap() {
);
}}
/>
<Route
path={AWAITING_SIGNATURES_ROUTE}
exact
render={() => {
return <AwaitingSignatures />;
}}
/>
<Route
path={AWAITING_SWAP_ROUTE}
exact

View File

@ -1,5 +1,6 @@
@import 'actionable-message/index';
@import 'awaiting-swap/index';
@import 'awaiting-signatures/index';
@import 'build-quote/index';
@import 'countdown-timer/index';
@import 'dropdown-input-pair/index';

View File

@ -75,6 +75,16 @@ export function getCurrentKeyring(state) {
return keyring;
}
/**
* Checks if the current wallet is a hardware wallet.
* @param {Object} state
* @returns {Boolean}
*/
export function isHardwareWallet(state) {
const keyring = getCurrentKeyring(state);
return keyring.type.includes('Hardware');
}
export function getAccountType(state) {
const currentKeyring = getCurrentKeyring(state);
const type = currentKeyring && currentKeyring.type;