Skip to content

Commit f2533f3

Browse files
feat: batch sell token select (#29690)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Adds the Batch Sell entry point and token selection flow for Unified Swaps. Users can select up to five same-network, non-zero-balance source tokens, excluding configured destination stablecoins, then hand them off to the quote flow. This also adds the single-token high-rate alert path, Batch Sell Redux handoff state, network/value sorting, wallet-style token row price data, empty state handling, and focused unit coverage for the new flow. This is hidden behind the env var `MM_BATCH_SELL_ENABLED`. ## **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: Added Batch Sell token selection for selling up to five same-network tokens. ## **Related issues** Refs: [SWAPS-4437](https://consensyssoftware.atlassian.net/browse/SWAPS-4437) ## **Manual testing steps** ```gherkin Feature: Batch Sell token selection Background: Given I am logged into MetaMask Mobile And Batch Sell is enabled Scenario: user opens Batch Sell from wallet actions Given I am on the wallet home screen When user opens the trade wallet actions menu And user taps "Batch Sell" Then the Batch Sell token selection screen should open And the subtitle should state that up to 5 tokens can be selected And the subtitle should state that all tokens must be on the same network Scenario: user selects multiple tokens for Batch Sell Given I am on the Batch Sell token selection screen And I have non-zero-balance tokens on a supported network When user selects between 2 and 5 tokens on the selected network Then the primary button should show "Continue with (N) tokens" When user taps the primary button Then the quote selector screen should open for the selected Batch Sell tokens Scenario: user changes networks and sorting Given I am on the Batch Sell token selection screen And multiple supported networks have sellable tokens When user selects a network pill Then the token list should show only non-zero-balance tokens from that network When user taps the Balance sort header Then the token list should reverse fiat balance sort direction Scenario: user has no sellable tokens Given I am on the Batch Sell token selection screen And I have no non-zero-balance tokens on any supported Batch Sell network Then the no sellable assets empty state should be shown And the "Explore tokens" button should be available When user taps "Explore tokens" Then the in-app Explore feed should open Scenario: user selects exactly one token Given I am on the Batch Sell token selection screen And exactly one token is selected When user taps "Continue with (1) token" Then the high rate alert modal should open When user taps "Yes, swap" Then the regular Unified Swap flow should open And the selected source token and configured destination stablecoin should be prefilled ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> N/A - not captured in this generated draft. ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/44d14a92-0f65-48fa-a57a-93bc2c2ecaa6 ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- Generated with the help of the pr-description AI skill --> [SWAPS-4437]: https://consensyssoftware.atlassian.net/browse/SWAPS-4437?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new Batch Sell entry point, navigation routes, and Redux handoff state, plus modifies shared token list UI (`TokenSelectorItem`) and token-balance filtering; regressions could affect token selection display/behavior and bridge/swaps navigation. > > **Overview** > Introduces a new **Batch Sell token selection** screen in Bridge, allowing users to pick up to `5` same-network tokens (filtered to supported chains, non-zero balances, and excluding configured destination stablecoins), with network pills, balance sort toggling, and an empty state that links to *Explore tokens*. > > Adds a **single-token high-rate alert** bottom sheet (`HighRateAlertModal`) that redirects users to the standard swaps flow, and wires multi-token selections to the quote flow via new bridge-slice state (`batchSellSourceTokens`) and selectors. > > Extends `TokenSelectorItem` with optional `secondaryRowContent`, configurable balance text props, and flags to disable selected-row styling and network badges (used by the new Batch Sell rows showing fiat price + 24h % change), and gates the wallet entry point behind `MM_BATCH_SELL_ENABLED` / `BATCH_SELL_ENABLED`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d095c9a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent fef5dc4 commit f2533f3

30 files changed

Lines changed: 2545 additions & 154 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import { Image } from 'react-native';
3+
import { SafeAreaView } from 'react-native-safe-area-context';
4+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
5+
import {
6+
Box,
7+
BoxAlignItems,
8+
BoxJustifyContent,
9+
Button,
10+
ButtonSize,
11+
ButtonVariant,
12+
Text,
13+
TextColor,
14+
TextVariant,
15+
} from '@metamask/design-system-react-native';
16+
import { strings } from '../../../../../../locales/i18n';
17+
import emptyStateDefiLight from '../../../../../images/empty-state-defi-light.png';
18+
import { BatchSellTokenSelectSelectorsIDs } from './BatchSellTokenSelect.testIds';
19+
20+
interface BatchSellEmptyStateProps {
21+
onExploreTokensPress: () => void;
22+
}
23+
24+
export function BatchSellEmptyState({
25+
onExploreTokensPress,
26+
}: BatchSellEmptyStateProps) {
27+
const tw = useTailwind();
28+
29+
return (
30+
<SafeAreaView style={tw.style('flex-1 bg-default')} edges={['bottom']}>
31+
<Box
32+
testID={BatchSellTokenSelectSelectorsIDs.EMPTY_STATE}
33+
alignItems={BoxAlignItems.Center}
34+
justifyContent={BoxJustifyContent.Center}
35+
gap={3}
36+
twClassName="flex-1 px-4 py-4"
37+
>
38+
<Image
39+
source={emptyStateDefiLight}
40+
resizeMode="contain"
41+
style={tw.style('h-[72px] w-[72px]')}
42+
/>
43+
<Box alignItems={BoxAlignItems.Center} gap={3} twClassName="px-4">
44+
<Text
45+
variant={TextVariant.BodyMd}
46+
color={TextColor.TextAlternative}
47+
twClassName="w-[240px] text-center"
48+
>
49+
{strings('bridge.batch_sell_empty_state_description')}
50+
</Text>
51+
<Button
52+
variant={ButtonVariant.Secondary}
53+
size={ButtonSize.Lg}
54+
onPress={onExploreTokensPress}
55+
twClassName="self-center"
56+
testID={BatchSellTokenSelectSelectorsIDs.EXPLORE_TOKENS_BUTTON}
57+
>
58+
{strings('bridge.explore_tokens')}
59+
</Button>
60+
</Box>
61+
</Box>
62+
</SafeAreaView>
63+
);
64+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import React, { useMemo } from 'react';
2+
import { ImageSourcePropType, StyleSheet, View } from 'react-native';
3+
import { useSelector } from 'react-redux';
4+
import {
5+
Checkbox,
6+
FontWeight,
7+
Text,
8+
TextColor as DSTextColor,
9+
TextVariant as DSTextVariant,
10+
} from '@metamask/design-system-react-native';
11+
import { getNativeTokenAddress } from '@metamask/assets-controllers';
12+
import {
13+
formatChainIdToHex,
14+
isNativeAddress,
15+
} from '@metamask/bridge-controller';
16+
import { Hex } from '@metamask/utils';
17+
import {
18+
TextColor as ComponentLibraryTextColor,
19+
TextVariant as ComponentLibraryTextVariant,
20+
} from '../../../../../component-library/components/Texts/Text';
21+
import { RootState } from '../../../../../reducers';
22+
import {
23+
selectCurrencyRates,
24+
selectCurrentCurrency,
25+
} from '../../../../../selectors/currencyRateController';
26+
import { selectNativeCurrencyByChainId } from '../../../../../selectors/networkController';
27+
import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController';
28+
import { safeToChecksumAddress } from '../../../../../util/address';
29+
import { strings } from '../../../../../../locales/i18n';
30+
import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format';
31+
import { useTokenPricePercentageChange } from '../../../Tokens/hooks/useTokenPricePercentageChange';
32+
import { TokenSelectorItem } from '../../components/TokenSelectorItem';
33+
import { BridgeToken } from '../../types';
34+
35+
const styles = StyleSheet.create({
36+
secondaryRow: {
37+
flexDirection: 'row',
38+
alignItems: 'center',
39+
flexShrink: 1,
40+
minWidth: 0,
41+
},
42+
priceText: {
43+
flexShrink: 1,
44+
minWidth: 0,
45+
},
46+
tokenBalance: {
47+
paddingHorizontal: 0,
48+
textAlign: 'right',
49+
},
50+
});
51+
52+
function getPricePercentChangeText(
53+
pricePercentChange: number | undefined,
54+
): string | undefined {
55+
if (
56+
pricePercentChange === undefined ||
57+
!Number.isFinite(pricePercentChange)
58+
) {
59+
return undefined;
60+
}
61+
62+
return `${pricePercentChange >= 0 ? '+' : ''}${pricePercentChange.toFixed(
63+
2,
64+
)}%`;
65+
}
66+
67+
function getPricePercentChangeTextColor(
68+
pricePercentChange: number,
69+
): DSTextColor {
70+
if (pricePercentChange > 0) {
71+
return DSTextColor.SuccessDefault;
72+
}
73+
74+
if (pricePercentChange < 0) {
75+
return DSTextColor.ErrorDefault;
76+
}
77+
78+
return DSTextColor.TextAlternative;
79+
}
80+
81+
function getTokenPriceInFiat({
82+
token,
83+
chainId,
84+
isNative,
85+
tokenMarketData,
86+
currencyRates,
87+
nativeCurrency,
88+
}: {
89+
token: BridgeToken;
90+
chainId: Hex;
91+
isNative: boolean;
92+
tokenMarketData: ReturnType<typeof selectTokenMarketData>;
93+
currencyRates: ReturnType<typeof selectCurrencyRates>;
94+
nativeCurrency?: string;
95+
}): number | undefined {
96+
const addressToUse = isNative
97+
? getNativeTokenAddress(chainId)
98+
: safeToChecksumAddress(token.address);
99+
const marketPriceInNative =
100+
tokenMarketData?.[chainId]?.[addressToUse as Hex]?.price;
101+
102+
if (marketPriceInNative != null) {
103+
const nativeToFiatRate = nativeCurrency
104+
? currencyRates?.[nativeCurrency]?.conversionRate
105+
: undefined;
106+
107+
return nativeToFiatRate
108+
? marketPriceInNative * nativeToFiatRate
109+
: undefined;
110+
}
111+
112+
const nativePriceInFiat = isNative
113+
? currencyRates?.[token.symbol]?.conversionRate
114+
: undefined;
115+
116+
return nativePriceInFiat ?? undefined;
117+
}
118+
119+
interface BatchSellTokenRowProps {
120+
token: BridgeToken;
121+
isSelected: boolean;
122+
networkName?: string;
123+
networkImageSource?: ImageSourcePropType;
124+
onTokenPress: (token: BridgeToken) => void;
125+
}
126+
127+
export function BatchSellTokenRow({
128+
token,
129+
isSelected,
130+
networkName,
131+
networkImageSource,
132+
onTokenPress,
133+
}: BatchSellTokenRowProps) {
134+
const chainId = formatChainIdToHex(token.chainId);
135+
const isNative = isNativeAddress(token.address);
136+
const tokenMarketData = useSelector(selectTokenMarketData);
137+
const currencyRates = useSelector(selectCurrencyRates);
138+
const currentCurrency = useSelector(selectCurrentCurrency);
139+
const nativeCurrency = useSelector((state: RootState) =>
140+
selectNativeCurrencyByChainId(state, chainId),
141+
);
142+
const pricePercentChange = useTokenPricePercentageChange({
143+
address: token.address,
144+
chainId,
145+
isNative,
146+
});
147+
const tokenPriceInFiat = useMemo(
148+
() =>
149+
getTokenPriceInFiat({
150+
token,
151+
chainId,
152+
isNative,
153+
tokenMarketData,
154+
currencyRates,
155+
nativeCurrency,
156+
}),
157+
[token, chainId, isNative, tokenMarketData, currencyRates, nativeCurrency],
158+
);
159+
const tokenPriceText =
160+
tokenPriceInFiat !== undefined
161+
? formatPriceWithSubscriptNotation(tokenPriceInFiat, currentCurrency)
162+
: undefined;
163+
const pricePercentChangeText = getPricePercentChangeText(pricePercentChange);
164+
const pricePercentChangeTextColor =
165+
pricePercentChangeText && pricePercentChange !== undefined
166+
? getPricePercentChangeTextColor(pricePercentChange)
167+
: undefined;
168+
const secondaryRowContent =
169+
tokenPriceText || pricePercentChangeText ? (
170+
<View style={styles.secondaryRow}>
171+
{tokenPriceText && (
172+
<Text
173+
variant={DSTextVariant.BodySm}
174+
fontWeight={FontWeight.Medium}
175+
color={DSTextColor.TextAlternative}
176+
numberOfLines={1}
177+
style={styles.priceText}
178+
>
179+
{tokenPriceText}
180+
</Text>
181+
)}
182+
{tokenPriceText && pricePercentChangeText && (
183+
<Text
184+
variant={DSTextVariant.BodySm}
185+
fontWeight={FontWeight.Medium}
186+
color={DSTextColor.TextAlternative}
187+
>
188+
{' • '}
189+
</Text>
190+
)}
191+
{pricePercentChangeText && pricePercentChangeTextColor && (
192+
<Text
193+
variant={DSTextVariant.BodySm}
194+
fontWeight={FontWeight.Medium}
195+
color={pricePercentChangeTextColor}
196+
numberOfLines={1}
197+
>
198+
{pricePercentChangeText}
199+
</Text>
200+
)}
201+
</View>
202+
) : undefined;
203+
204+
return (
205+
<TokenSelectorItem
206+
token={token}
207+
onPress={onTokenPress}
208+
networkName={networkName}
209+
networkImageSource={networkImageSource}
210+
isSelected={isSelected}
211+
shouldChangeSelectedStyle={false}
212+
shouldShowNetworkIcon={false}
213+
secondaryRowContent={secondaryRowContent}
214+
tokenBalanceTextProps={{
215+
textVariant: ComponentLibraryTextVariant.BodySMMedium,
216+
textColor: ComponentLibraryTextColor.Alternative,
217+
textStyle: styles.tokenBalance,
218+
}}
219+
>
220+
<Checkbox
221+
isSelected={isSelected}
222+
onChange={() => onTokenPress(token)}
223+
accessibilityLabel={`${token.symbol} ${strings(
224+
'bridge.batch_sell_checkbox_label',
225+
)}`}
226+
/>
227+
</TokenSelectorItem>
228+
);
229+
}

0 commit comments

Comments
 (0)