Skip to content

Commit 45b4b55

Browse files
authored
Merge branch 'main' into feat/trezor-erc721-e2e
2 parents 3f31036 + 3299a33 commit 45b4b55

20 files changed

+846
-49
lines changed

app/_locales/en/messages.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/_locales/en_GB/messages.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/pages/confirmations/components/send/amount/amount.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import mockState from '../../../../../../test/data/mock-state.json';
55
import { renderWithProvider } from '../../../../../../test/jest';
66
import configureStore from '../../../../../store/store';
77
import * as BalanceFunctions from '../../../hooks/send/useBalance';
8+
import * as CurrencyConversions from '../../../hooks/send/useCurrencyConversions';
89
import * as SendContext from '../../../context/send';
910
import { Amount } from './amount';
1011

@@ -44,6 +45,34 @@ describe('Amount', () => {
4445
expect(mockUpdateValue).toHaveBeenCalledWith('1');
4546
});
4647

48+
it('amount input is reset when fiatmode is toggled', () => {
49+
const { getByRole, getByText } = render();
50+
51+
fireEvent.change(getByRole('textbox'), { target: { value: 100 } });
52+
expect(getByText('Fiat Mode')).toBeInTheDocument();
53+
fireEvent.click(getByText('Fiat Mode'));
54+
expect(getByText('Native Mode')).toBeInTheDocument();
55+
expect(getByRole('textbox')).toHaveValue('');
56+
});
57+
58+
it('if fiatmode is enbled call update value with converted values method when value is changed', () => {
59+
const mockUpdateValue = jest.fn();
60+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
61+
updateValue: mockUpdateValue,
62+
} as unknown as SendContext.SendContextType);
63+
jest.spyOn(CurrencyConversions, 'useCurrencyConversions').mockReturnValue({
64+
fiatCurrencySymbol: 'USD',
65+
getFiatValue: () => '20',
66+
getNativeValue: () => '20',
67+
});
68+
69+
const { getByRole, getByText } = render();
70+
71+
fireEvent.click(getByText('Fiat Mode'));
72+
fireEvent.change(getByRole('textbox'), { target: { value: 1 } });
73+
expect(mockUpdateValue).toHaveBeenCalledWith('20');
74+
});
75+
4776
it('go to sendTo page when continue button is clicked', () => {
4877
const { getByText } = render();
4978

ui/pages/confirmations/components/send/amount/amount.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,52 @@
1-
import React, { useCallback } from 'react';
1+
import React, { useCallback, useState } from 'react';
22

33
import {
44
Button,
55
Text,
66
TextField,
77
} from '../../../../../components/component-library';
8+
import { useAmountValidation } from '../../../hooks/send/useAmountValidation';
89
import { useBalance } from '../../../hooks/send/useBalance';
10+
import { useCurrencyConversions } from '../../../hooks/send/useCurrencyConversions';
911
import { useNavigateSendPage } from '../../../hooks/send/useNavigateSendPage';
1012
import { useSendContext } from '../../../context/send';
1113
import { Header } from '../header';
1214

1315
export const Amount = () => {
16+
const [amount, setAmount] = useState('');
17+
const { amountError } = useAmountValidation();
18+
const { balance } = useBalance();
19+
const [faitMode, setFiatMode] = useState(false);
20+
const { getNativeValue } = useCurrencyConversions();
1421
const { goToSendToPage, goToPreviousPage } = useNavigateSendPage();
1522
const { updateValue } = useSendContext();
16-
const { balance } = useBalance();
1723

1824
const onChange = useCallback(
19-
(event) => updateValue(event.target.value),
20-
[updateValue],
25+
(event) => {
26+
const newValue = event.target.value;
27+
updateValue(faitMode ? getNativeValue(newValue) : newValue);
28+
setAmount(newValue);
29+
},
30+
[faitMode, getNativeValue, setAmount, updateValue],
2131
);
2232

33+
const toggleFiatMode = useCallback(() => {
34+
setAmount('');
35+
setFiatMode(!faitMode);
36+
}, [faitMode, setAmount, setFiatMode]);
37+
2338
return (
2439
<div className="send__wrapper">
2540
<div className="send__container">
2641
<div className="send__content">
2742
<Header />
2843
<p>AMOUNTs</p>
29-
<TextField onChange={onChange} />
44+
<TextField value={amount} onChange={onChange} />
3045
<Text>Balance: {balance}</Text>
46+
<Text>Error: {amountError}</Text>
47+
<Button onClick={toggleFiatMode}>
48+
{faitMode ? 'Native Mode' : 'Fiat Mode'}
49+
</Button>
3150
<Button onClick={goToPreviousPage}>Previous</Button>
3251
<Button onClick={goToSendToPage}>Continue</Button>
3352
</div>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { DefaultRootState } from 'react-redux';
2+
3+
import mockState from '../../../../../../test/data/mock-state.json';
4+
import {
5+
EVM_ASSET,
6+
EVM_NATIVE_ASSET,
7+
} from '../../../../../../test/data/send/assets';
8+
import { renderHookWithProvider } from '../../../../../../test/lib/render-helpers';
9+
import * as SendContext from '../../../context/send';
10+
import { useEvmAmountValidation } from './useEvmAmountValidation';
11+
12+
const MOCK_ADDRESS_1 = '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73';
13+
14+
const mockHistory = {
15+
goBack: jest.fn(),
16+
push: jest.fn(),
17+
};
18+
19+
jest.mock('react-router-dom', () => ({
20+
...jest.requireActual('react-router-dom'),
21+
useHistory: () => mockHistory,
22+
}));
23+
24+
jest.mock('react-redux', () => ({
25+
...jest.requireActual('react-redux'),
26+
useDispatch: () => async (fn: () => Promise<unknown>) => {
27+
if (fn) {
28+
await fn();
29+
}
30+
},
31+
}));
32+
33+
function renderHook(args: DefaultRootState = {}) {
34+
const { result } = renderHookWithProvider(useEvmAmountValidation, {
35+
...mockState,
36+
metamask: { ...mockState.metamask, ...args },
37+
});
38+
return result.current;
39+
}
40+
41+
describe('useEvmAmountValidation', () => {
42+
afterEach(() => {
43+
jest.clearAllMocks();
44+
});
45+
46+
it('does not return error if amount of native asset is less than balance', () => {
47+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
48+
asset: EVM_NATIVE_ASSET,
49+
from: MOCK_ADDRESS_1,
50+
value: 2,
51+
} as unknown as SendContext.SendContextType);
52+
53+
const result = renderHook({
54+
accountsByChainId: { '0x1': { [MOCK_ADDRESS_1]: { balance: '0x5' } } },
55+
});
56+
const error = result.validateEvmAmount();
57+
expect(error).toBeUndefined();
58+
});
59+
60+
it('does not return error for undefined amount value', () => {
61+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
62+
asset: EVM_NATIVE_ASSET,
63+
from: MOCK_ADDRESS_1,
64+
} as unknown as SendContext.SendContextType);
65+
66+
const result = renderHook({
67+
accountsByChainId: { '0x1': { [MOCK_ADDRESS_1]: { balance: '0x5' } } },
68+
});
69+
const error = result.validateEvmAmount();
70+
expect(error).toBeUndefined();
71+
});
72+
73+
it('return error for invalid amount value', () => {
74+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
75+
asset: EVM_NATIVE_ASSET,
76+
from: MOCK_ADDRESS_1,
77+
value: 'abc',
78+
} as unknown as SendContext.SendContextType);
79+
80+
const result = renderHook({
81+
accountsByChainId: { '0x1': { [MOCK_ADDRESS_1]: { balance: '0x5' } } },
82+
});
83+
const error = result.validateEvmAmount();
84+
expect(error).toEqual('Invalid value');
85+
});
86+
87+
it('return error if amount of native asset is more than balance', () => {
88+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
89+
asset: EVM_NATIVE_ASSET,
90+
from: MOCK_ADDRESS_1,
91+
value: 10,
92+
} as unknown as SendContext.SendContextType);
93+
94+
const result = renderHook({
95+
accountsByChainId: { '0x5': { [MOCK_ADDRESS_1]: { balance: '0x5' } } },
96+
});
97+
const error = result.validateEvmAmount();
98+
expect(error).toEqual('Insufficient funds');
99+
});
100+
101+
it('does not return error if amount of ERC20 asset is less than balance', () => {
102+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
103+
asset: EVM_ASSET,
104+
from: MOCK_ADDRESS_1,
105+
value: 2,
106+
} as unknown as SendContext.SendContextType);
107+
108+
const result = renderHook({
109+
tokenBalances: {
110+
[MOCK_ADDRESS_1]: {
111+
'0x5': {
112+
[EVM_ASSET.address]: 0x738,
113+
},
114+
},
115+
},
116+
});
117+
const error = result.validateEvmAmount();
118+
expect(error).toBeUndefined();
119+
});
120+
121+
it('return error if amount of ERC20 asset is more than balance', () => {
122+
jest.spyOn(SendContext, 'useSendContext').mockReturnValue({
123+
asset: EVM_ASSET,
124+
from: MOCK_ADDRESS_1,
125+
value: 2000,
126+
} as unknown as SendContext.SendContextType);
127+
128+
const result = renderHook({
129+
tokenBalances: {
130+
[MOCK_ADDRESS_1]: {
131+
'0x5': {
132+
[EVM_ASSET.address]: '0x738',
133+
},
134+
},
135+
},
136+
});
137+
const error = result.validateEvmAmount();
138+
expect(error).toEqual('Insufficient funds');
139+
});
140+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Hex } from '@metamask/utils';
2+
import { toHex } from '@metamask/controller-utils';
3+
import { isAddress as isEvmAddress } from 'ethers/lib/utils';
4+
import { isNativeAddress } from '@metamask/bridge-controller';
5+
import { useCallback, useMemo } from 'react';
6+
import { useSelector } from 'react-redux';
7+
8+
import { Numeric } from '../../../../../../shared/modules/Numeric';
9+
import { getTokenBalances } from '../../../../../ducks/metamask/metamask';
10+
import { useI18nContext } from '../../../../../hooks/useI18nContext';
11+
import { Asset } from '../../../types/send';
12+
import { fromTokenMinimalUnitsNumeric, isDecimal } from '../../../utils/send';
13+
import { useSendContext } from '../../../context/send';
14+
15+
type AccountWithBalances = Record<Hex, { balance: Hex }>;
16+
type TokenBalances = Record<Hex, Record<Hex, Record<Hex, Hex>>>;
17+
type MetamaskSendState = {
18+
metamask: { accountsByChainId: Record<Hex, AccountWithBalances> };
19+
};
20+
type ValidateAmountFnArgs = {
21+
accountsWithBalances?: AccountWithBalances;
22+
amount?: string;
23+
asset?: Asset;
24+
from: Hex;
25+
t: ReturnType<typeof useI18nContext>;
26+
tokenBalances: TokenBalances;
27+
};
28+
29+
const validateAmountFn = ({
30+
accountsWithBalances,
31+
amount,
32+
asset,
33+
from,
34+
t,
35+
tokenBalances,
36+
}: ValidateAmountFnArgs): string | undefined => {
37+
if (!asset || amount === undefined || amount === null || amount === '') {
38+
return undefined;
39+
}
40+
if (!isDecimal(amount) || Number(amount) < 0) {
41+
return t('invalidValue');
42+
}
43+
let weiValue;
44+
let weiBalance;
45+
if (isNativeAddress(asset.address)) {
46+
if (!accountsWithBalances) {
47+
return undefined;
48+
}
49+
const accountAddress = Object.keys(accountsWithBalances).find(
50+
(address) => address.toLowerCase() === from.toLowerCase(),
51+
) as Hex;
52+
const account = accountsWithBalances[accountAddress];
53+
try {
54+
weiValue = fromTokenMinimalUnitsNumeric(amount, asset.decimals);
55+
} catch (error) {
56+
console.log(error);
57+
return t('invalidValue');
58+
}
59+
weiBalance = new Numeric(account?.balance ?? '0', 16);
60+
} else {
61+
weiValue = fromTokenMinimalUnitsNumeric(amount, asset.decimals ?? 0);
62+
weiBalance = new Numeric(
63+
(
64+
Object.values(tokenBalances[from as Hex]).find(
65+
(chainTokenBalances: Record<Hex, Hex>) =>
66+
chainTokenBalances?.[asset?.address as Hex],
67+
) as Record<Hex, Hex>
68+
)?.[asset?.address as Hex],
69+
16,
70+
);
71+
}
72+
if (weiBalance.lessThan(weiValue)) {
73+
return t('insufficientFundsSend');
74+
}
75+
return undefined;
76+
};
77+
78+
export const useEvmAmountValidation = () => {
79+
const t = useI18nContext();
80+
const tokenBalances = useSelector(getTokenBalances);
81+
const { asset, from, value } = useSendContext();
82+
const accountsByChainId = useSelector(
83+
(state: MetamaskSendState) => state.metamask.accountsByChainId,
84+
) as AccountWithBalances;
85+
const accountsWithBalances = useMemo(() => {
86+
if (asset?.chainId && asset?.address && isEvmAddress(asset?.address)) {
87+
return accountsByChainId[toHex(asset?.chainId)];
88+
}
89+
return undefined;
90+
}, [accountsByChainId, asset?.address, asset?.chainId]);
91+
92+
const validateEvmAmount = useCallback(
93+
() =>
94+
validateAmountFn({
95+
accountsWithBalances,
96+
amount: value,
97+
asset,
98+
tokenBalances,
99+
from: from as Hex,
100+
t,
101+
}),
102+
[accountsWithBalances, asset, from, t, tokenBalances, value],
103+
);
104+
105+
return { validateEvmAmount };
106+
};

0 commit comments

Comments
 (0)