Skip to content

Commit ef7a763

Browse files
authored
feat: Adding solana infrastructure to send page (MetaMask#17771)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Use new send flow for solana assets. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: MetaMask/MetaMask-planning#5463 ## **Manual testing steps** 1. Enable new send flow locally 2. Start send on solana network 3. Check new send page rendered ## **Screenshots/Recordings** https://github.com/user-attachments/assets/264cbc01-0120-4f06-b103-1aea0c0a10cb ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.
1 parent 6e1868c commit ef7a763

40 files changed

Lines changed: 1669 additions & 801 deletions

app/components/UI/AssetOverview/AssetOverview.test.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -737,8 +737,19 @@ describe('AssetOverview', () => {
737737
// Verify hook was called with correct parameters
738738
expect(useSendNonEvmAsset).toHaveBeenCalledWith({
739739
asset: {
740-
chainId: SolScope.Mainnet,
741-
address: solanaAsset.address,
740+
address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
741+
aggregators: [],
742+
balance: '400',
743+
balanceFiat: '1500',
744+
chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
745+
decimals: 18,
746+
hasBalanceError: false,
747+
image: '',
748+
isETH: undefined,
749+
isNative: true,
750+
logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg',
751+
name: 'Ethereum',
752+
symbol: 'ETH',
742753
},
743754
});
744755

app/components/UI/AssetOverview/AssetOverview.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,7 @@ const AssetOverview: React.FC<AssetOverviewProps> = ({
156156
});
157157

158158
// Hook for handling non-EVM asset sending
159-
const { sendNonEvmAsset } = useSendNonEvmAsset({
160-
asset: {
161-
chainId: asset.chainId as string,
162-
address: asset.address,
163-
},
164-
});
159+
const { sendNonEvmAsset } = useSendNonEvmAsset({ asset });
165160

166161
const { styles } = useStyles(styleSheet, {});
167162
const dispatch = useDispatch();

app/components/Views/confirmations/__mocks__/send.mock.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
import { Hex } from '@metamask/utils';
2-
import { backgroundState } from '../../../../util/test/initial-root-state';
32
import { InternalAccount } from '@metamask/keyring-internal-api';
43

5-
export const ACCOUNT_ADDRESS_MOCK_1 = '0x12345' as Hex;
6-
export const TOKEN_ADDRESS_MOCK_1 = '0x123' as Hex;
4+
import { ProviderValues } from '../../../../util/test/renderWithProvider';
5+
import { backgroundState } from '../../../../util/test/initial-root-state';
6+
7+
export const ACCOUNT_ADDRESS_MOCK_1 =
8+
'0xeDd1935e28b253C7905Cf5a944f0B5830FFA916a' as Hex;
9+
export const TOKEN_ADDRESS_MOCK_1 =
10+
'0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477' as Hex;
11+
export const ACCOUNT_ADDRESS_MOCK_2 =
12+
'14grJpemFaf88c8tiVb77W7TYg2W3ir6pfkKz3YjhhZ5';
13+
14+
export const SOLANA_ASSET = {
15+
address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
16+
aggregators: [],
17+
balance: '400',
18+
balanceFiat: '1500',
19+
chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
20+
decimals: 18,
21+
hasBalanceError: false,
22+
image: '',
23+
isETH: undefined,
24+
isNative: true,
25+
logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg',
26+
name: 'Ethereum',
27+
symbol: 'ETH',
28+
};
729

830
export const evmSendStateMock = {
931
engine: {
@@ -19,6 +41,11 @@ export const evmSendStateMock = {
1941
address: ACCOUNT_ADDRESS_MOCK_1,
2042
metadata: {},
2143
},
44+
'solana-account-id': {
45+
id: 'solana-account-id',
46+
address: ACCOUNT_ADDRESS_MOCK_2,
47+
metadata: {},
48+
},
2249
},
2350
},
2451
},
@@ -59,4 +86,19 @@ export const evmSendStateMock = {
5986
},
6087
},
6188
},
62-
};
89+
} as ProviderValues['state'];
90+
91+
export const solanaSendStateMock = {
92+
engine: {
93+
backgroundState: {
94+
...evmSendStateMock?.engine?.backgroundState,
95+
AccountsController: {
96+
internalAccounts: {
97+
...evmSendStateMock?.engine?.backgroundState?.AccountsController
98+
?.internalAccounts,
99+
selectedAccount: 'solana-account-id',
100+
},
101+
},
102+
},
103+
},
104+
} as ProviderValues['state'];

app/components/Views/confirmations/components/send/amount/amount.test.tsx

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import React from 'react';
22

3-
import { backgroundState } from '../../../../../../util/test/initial-root-state';
43
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
4+
// eslint-disable-next-line import/no-namespace
5+
import * as MaxAmountUtils from '../../../hooks/send/useMaxAmount';
6+
// eslint-disable-next-line import/no-namespace
7+
import * as ConversionUtils from '../../../hooks/send/useConversions';
58
import { SendContextProvider } from '../../../context/send-context';
9+
import { evmSendStateMock } from '../../../__mocks__/send.mock';
610
import { Amount } from './amount';
11+
import { fireEvent } from '@testing-library/react-native';
712

813
jest.mock(
914
'../../../../../../components/Views/confirmations/hooks/gas/useGasFeeEstimates',
@@ -35,11 +40,7 @@ const renderComponent = () =>
3540
<Amount />
3641
</SendContextProvider>,
3742
{
38-
state: {
39-
engine: {
40-
backgroundState,
41-
},
42-
},
43+
state: evmSendStateMock,
4344
},
4445
);
4546

@@ -56,9 +57,55 @@ describe('Amount', () => {
5657
expect(getByText('Max')).toBeTruthy();
5758
});
5859

60+
it('does not display Max option if it is not supported', async () => {
61+
jest.spyOn(MaxAmountUtils, 'useMaxAmount').mockReturnValue({
62+
getMaxAmount: () => undefined,
63+
isMaxAmountSupported: false,
64+
});
65+
const { queryByText } = renderComponent();
66+
67+
expect(queryByText('Max')).toBeNull();
68+
});
69+
70+
it('update amount with max value when max button is clicked', async () => {
71+
const MAX_AMOUNT = '0.01234';
72+
jest.spyOn(MaxAmountUtils, 'useMaxAmount').mockReturnValue({
73+
getMaxAmount: () => MAX_AMOUNT,
74+
isMaxAmountSupported: true,
75+
});
76+
const { getByTestId, getByText } = renderComponent();
77+
fireEvent.press(getByText('Max'));
78+
expect(getByTestId('send_amount').props.value).toBe(MAX_AMOUNT);
79+
});
80+
5981
it('display option for fiat toggle', async () => {
6082
const { getByTestId } = renderComponent();
6183

6284
expect(getByTestId('fiat_toggle')).toBeTruthy();
6385
});
86+
87+
it('displays fiat value for the amount entered', async () => {
88+
jest.spyOn(ConversionUtils, 'useConversions').mockReturnValue({
89+
getFiatDisplayValue: () => '$ 1200.00',
90+
getFiatValue: () => 0,
91+
getNativeDisplayValue: () => '',
92+
getNativeValue: () => '',
93+
});
94+
const { getByTestId, getByText } = renderComponent();
95+
fireEvent.changeText(getByTestId('send_amount'), '123');
96+
expect(getByText('$ 1200.00')).toBeDefined();
97+
});
98+
99+
it('displays native value for the amount entered if fiat_mode is enabled', async () => {
100+
jest.spyOn(ConversionUtils, 'useConversions').mockReturnValue({
101+
getFiatDisplayValue: () => '',
102+
getFiatValue: () => 0,
103+
getNativeDisplayValue: () => 'ETH 0.001',
104+
getNativeValue: () => '',
105+
});
106+
const { getByTestId, getByText } = renderComponent();
107+
fireEvent.press(getByTestId('fiat_toggle'));
108+
fireEvent.changeText(getByTestId('send_amount'), '123');
109+
expect(getByText('ETH 0.001')).toBeDefined();
110+
});
64111
});

app/components/Views/confirmations/components/send/amount/amount.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ import { styleSheet } from './amount.styles';
2020
export const Amount = () => {
2121
const { styles } = useStyles(styleSheet, {});
2222
const { updateValue } = useSendContext();
23-
const { getMaxAmount } = useMaxAmount();
23+
const { getMaxAmount, isMaxAmountSupported } = useMaxAmount();
2424
const [amount, updateAmount] = useState('');
2525
const { amountError } = useAmountValidation();
26-
const primaryCurrency = useSelector(selectPrimaryCurrency) ?? 'ETH';
26+
const primaryCurrency = useSelector(selectPrimaryCurrency);
2727
const [fiatMode, setFiatMode] = useState(primaryCurrency === 'Fiat');
2828
const {
2929
getFiatDisplayValue,
@@ -44,8 +44,10 @@ export const Amount = () => {
4444

4545
const updateToMaxAmount = useCallback(() => {
4646
const maxAmount = getMaxAmount();
47-
updateAmount(fiatMode ? getFiatValue(maxAmount).toString() : maxAmount);
48-
updateValue(maxAmount);
47+
if (maxAmount !== undefined) {
48+
updateAmount(fiatMode ? getFiatValue(maxAmount).toString() : maxAmount);
49+
updateValue(maxAmount);
50+
}
4951
}, [fiatMode, getFiatValue, getMaxAmount, updateAmount, updateValue]);
5052

5153
const updateToNewAmount = useCallback(
@@ -80,11 +82,13 @@ export const Amount = () => {
8082
testID="fiat_toggle"
8183
/>
8284
<Text color={TextColor.Error}>{amountError}</Text>
83-
<Button
84-
label="Max"
85-
onPress={updateToMaxAmount}
86-
variant={ButtonVariants.Secondary}
87-
/>
85+
{isMaxAmountSupported && (
86+
<Button
87+
label="Max"
88+
onPress={updateToMaxAmount}
89+
variant={ButtonVariants.Secondary}
90+
/>
91+
)}
8892
</View>
8993
);
9094
};

app/components/Views/confirmations/components/send/send-root/send-root.test.tsx

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import React from 'react';
22
import { ParamListBase, RouteProp, useRoute } from '@react-navigation/native';
33
import { TransactionMeta } from '@metamask/transaction-controller';
44
import { act, fireEvent } from '@testing-library/react-native';
5+
import { merge } from 'lodash';
56

67
import Engine from '../../../../../../core/Engine';
7-
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
8+
import renderWithProvider, {
9+
ProviderValues,
10+
} from '../../../../../../util/test/renderWithProvider';
811
// eslint-disable-next-line import/no-namespace
912
import * as TransactionUtils from '../../../../../../util/transaction-controller';
1013
import { SendContextProvider } from '../../../context/send-context';
@@ -47,20 +50,25 @@ jest.mock('@react-navigation/native', () => ({
4750
params: {
4851
asset: {
4952
chainId: '0x1',
53+
address: '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477',
5054
},
5155
},
5256
}),
5357
}));
5458

55-
const renderComponent = () =>
56-
renderWithProvider(
59+
const renderComponent = (mockState?: ProviderValues['state']) => {
60+
const state = mockState
61+
? merge(evmSendStateMock, mockState)
62+
: evmSendStateMock;
63+
return renderWithProvider(
5764
<SendContextProvider>
5865
<SendRoot />
5966
</SendContextProvider>,
6067
{
61-
state: evmSendStateMock,
68+
state,
6269
},
6370
);
71+
};
6472

6573
describe('SendRoot', () => {
6674
beforeEach(() => {
@@ -107,21 +115,6 @@ describe('SendRoot', () => {
107115
expect(mockAddTransaction).not.toHaveBeenCalled();
108116
});
109117

110-
it('display error if amount is greater than balance for native token', async () => {
111-
(useRoute as jest.MockedFn<typeof useRoute>).mockReturnValue({
112-
params: {
113-
asset: {
114-
isNative: true,
115-
chainId: '0x1',
116-
},
117-
},
118-
} as RouteProp<ParamListBase, string>);
119-
120-
const { getByText, getByTestId } = renderComponent();
121-
fireEvent.changeText(getByTestId('send_amount'), '100');
122-
expect(getByText('Insufficient funds')).toBeTruthy();
123-
});
124-
125118
it('when confirm is clicked create transaction for ERC20 token', async () => {
126119
const mockAddTransaction = jest
127120
.spyOn(TransactionUtils, 'addTransaction')
@@ -163,6 +156,21 @@ describe('SendRoot', () => {
163156
).toBeTruthy();
164157
});
165158

159+
it('display error if amount is greater than balance for native token', async () => {
160+
(useRoute as jest.MockedFn<typeof useRoute>).mockReturnValue({
161+
params: {
162+
asset: {
163+
isNative: true,
164+
chainId: '0x1',
165+
},
166+
},
167+
} as RouteProp<ParamListBase, string>);
168+
169+
const { getByText, getByTestId } = renderComponent();
170+
fireEvent.changeText(getByTestId('send_amount'), '100');
171+
expect(getByText('Insufficient funds')).toBeTruthy();
172+
});
173+
166174
it('display error if amount is greater than balance for ERC20 token', async () => {
167175
(useRoute as jest.MockedFn<typeof useRoute>).mockReturnValue({
168176
params: {
@@ -267,6 +275,7 @@ describe('SendRoot', () => {
267275
asset: {
268276
isNative: true,
269277
chainId: '0x1',
278+
address: TOKEN_ADDRESS_MOCK_1,
270279
},
271280
},
272281
} as RouteProp<ParamListBase, string>);
@@ -275,7 +284,7 @@ describe('SendRoot', () => {
275284
expect(getByTestId('send_amount').props.value).toBe('');
276285
fireEvent.press(getByText('Max'));
277286
expect(getByTestId('send_amount').props.value).toBe('0.9999685');
278-
expect(getByText('$ 0.99')).toBeTruthy();
287+
expect(getByText('$ 3889.87')).toBeTruthy();
279288
});
280289

281290
it('display fiat conversion of amount entered', async () => {
Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React from 'react';
22

3-
import { backgroundState } from '../../../../../../util/test/initial-root-state';
43
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
4+
// eslint-disable-next-line import/no-namespace
5+
import * as ToAddressValidationUtils from '../../../hooks/send/useToAddressValidation';
56
import { SendContextProvider } from '../../../context/send-context';
7+
import { evmSendStateMock } from '../../../__mocks__/send.mock';
68
import { SendTo } from './send-to';
79

810
const renderComponent = () =>
@@ -11,11 +13,7 @@ const renderComponent = () =>
1113
<SendTo />
1214
</SendContextProvider>,
1315
{
14-
state: {
15-
engine: {
16-
backgroundState,
17-
},
18-
},
16+
state: evmSendStateMock,
1917
},
2018
);
2119

@@ -25,4 +23,17 @@ describe('SendTo', () => {
2523

2624
expect(getByText('To:')).toBeTruthy();
2725
});
26+
27+
it('display error and warning if present', async () => {
28+
jest
29+
.spyOn(ToAddressValidationUtils, 'useToAddressValidation')
30+
.mockReturnValue({
31+
toAddressError: 'Error in recipient address',
32+
toAddressWarning: 'Warning in recipient address',
33+
});
34+
35+
const { getByText } = renderComponent();
36+
expect(getByText('Error in recipient address')).toBeTruthy();
37+
expect(getByText('Warning in recipient address')).toBeTruthy();
38+
});
2839
});

0 commit comments

Comments
 (0)