Skip to content

Commit e0ce2e9

Browse files
chore(runway): cherry-pick fix(predict): disable Deposit Wallet withdrawals (#30146)
- fix(predict): disable Deposit Wallet withdrawals (#29941) ## **Description** Temporarily disables the Predict withdraw entry point for Deposit Wallet users until Deposit Wallet withdrawals are fully supported. Deposit Wallet users with a Predict balance now see a dismissible bottom sheet explaining that withdrawals are currently unavailable and to contact Customer Service for assistance. Legacy/Safe users continue through the existing withdraw flow. This is intentionally a temporary UI guard and should be removed once Deposit Wallet withdraw support ships. ## **Changelog** CHANGELOG entry: Fixed Predict withdrawals to show a temporary unavailable message for Deposit Wallet users. ## **Related issues** Fixes: [PRED-869](https://consensyssoftware.atlassian.net/browse/PRED-869) ## **Manual testing steps** ```gherkin Feature: Predict Deposit Wallet withdrawals Scenario: Deposit Wallet user sees withdrawals unavailable notice Given a Predict Deposit Wallet user has an available balance When user taps Withdraw on the Predict balance card Then a bottom sheet is displayed with the title "Withdrawals are currently unavailable" And the bottom sheet description says "For assistance withdrawing your funds, please contact Customer Service." And tapping "Got it" dismisses the bottom sheet Scenario: Safe user keeps the existing withdraw flow Given a Predict Safe user has an available balance When user taps Withdraw on the Predict balance card Then the existing withdraw flow is opened And the withdrawals unavailable bottom sheet is not displayed ``` Validation run locally: - `yarn lint:tsc` - `yarn eslint app/components/UI/Predict/components/PredictWithdrawUnavailableSheet/PredictWithdrawUnavailableSheet.tsx app/components/UI/Predict/components/PredictWithdrawUnavailableSheet/PredictWithdrawUnavailableSheet.test.tsx --cache` - `yarn jest app/components/UI/Predict/components/PredictWithdrawUnavailableSheet/PredictWithdrawUnavailableSheet.test.tsx --runInBand` printed PASS for all tests, then hit the known local Jest OOM after completion. ## **Screenshots/Recordings** ### **Before** N/A - PR changes the Deposit Wallet withdraw press behavior from launching unsupported withdraw handling to displaying a temporary unavailable bottom sheet. ### **After** Manually verified in the iOS simulator: Deposit Wallet users see the withdrawals unavailable bottom sheet and can dismiss it with "Got it". <img width="300" alt="Simulator Screenshot - mm-blue - 2026-05-08 at 20 39 36" src="https://github.com/user-attachments/assets/544500b4-2456-4dfd-b6af-e56bbb844c41" /> ## **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. #### Performance checks (if applicable) - [x] N/A - no performance-sensitive code path changed; Android performance testing is not applicable. - Ideally on a mid-range device; emulator is acceptable - [x] N/A - no performance-sensitive code path changed; power-user scenario testing is not applicable. - 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 - [x] N/A - no new production performance instrumentation is required for this temporary UI guard. - 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** - [ ] 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. [PRED-869]: https://consensyssoftware.atlassian.net/browse/PRED-869?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the withdrawal entry point behavior in the Predict balance card based on detected wallet type, which can block or allow access to a funds-moving flow. Risk is mitigated by added unit/E2E coverage and a conservative default that disables withdraw while account state is unknown. > > **Overview** > **Disables Predict withdrawals for Deposit Wallet users** by gating the `Withdraw` button on `usePredictAccountState` and routing Deposit Wallet presses to a temporary “withdrawals unavailable” bottom sheet. > > Adds the new `PredictWithdrawUnavailableSheet` component (with i18n strings and test IDs) and wires it into `PredictFeed` so it can be opened via a ref callback; Safe/legacy users continue to call `withdraw()` as before, and the Withdraw button is disabled until wallet type is resolved. > > Updates unit tests and Detox/E2E mocks to cover wallet-type branching and keep the withdraw smoke test on the legacy Safe path (new `LEGACY_SAFE_WALLET_ADDRESS` + `POLYMARKET_LEGACY_SAFE_ACCOUNT_MOCKS`). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c60a694. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [b44e29a](b44e29a) [PRED-869]: https://consensyssoftware.atlassian.net/browse/PRED-869?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [PRED-869]: https://consensyssoftware.atlassian.net/browse/PRED-869?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 9342d0a commit e0ce2e9

13 files changed

Lines changed: 383 additions & 12 deletions

File tree

app/components/UI/Predict/Predict.testIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export const getPredictSearchSelector = {
265265

266266
export const PredictBalanceSelectorsIDs = {
267267
BALANCE_CARD: 'predict-balance-card',
268+
WITHDRAW_BUTTON: 'predict-balance-withdraw-button',
268269
} as const;
269270

270271
export const PredictBalanceSelectorsText = {

app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
55
import PredictBalance from './PredictBalance';
66
import { strings } from '../../../../../../locales/i18n';
77
import { ButtonVariants } from '../../../../../component-library/components/Buttons/Button';
8+
import { PREDICT_BALANCE_TEST_IDS } from './PredictBalance.testIds';
89

910
// Mock React Query
1011
jest.mock('@tanstack/react-query', () => ({
@@ -30,6 +31,13 @@ jest.mock('../../hooks/usePredictBalance', () => ({
3031
usePredictBalance: (options?: unknown) => mockUsePredictBalance(options),
3132
}));
3233

34+
// Mock usePredictAccountState hook
35+
const mockUsePredictAccountState = jest.fn();
36+
jest.mock('../../hooks/usePredictAccountState', () => ({
37+
usePredictAccountState: (options?: unknown) =>
38+
mockUsePredictAccountState(options),
39+
}));
40+
3341
// Mock usePredictDeposit hook
3442
const mockUsePredictDeposit = jest.fn();
3543
jest.mock('../../hooks/usePredictDeposit', () => ({
@@ -83,6 +91,15 @@ describe('PredictBalance', () => {
8391
isDepositPending: false,
8492
});
8593

94+
mockUsePredictAccountState.mockReturnValue({
95+
data: {
96+
address: '0x1111111111111111111111111111111111111111',
97+
isDeployed: true,
98+
walletType: 'safe',
99+
},
100+
isLoading: false,
101+
});
102+
86103
mockUsePredictWithdraw.mockReturnValue({
87104
withdraw: jest.fn(),
88105
});
@@ -347,7 +364,7 @@ describe('PredictBalance', () => {
347364
expect(mockDeposit).toHaveBeenCalled();
348365
});
349366

350-
it('calls withdraw directly when Withdraw button is pressed', () => {
367+
it('calls withdraw directly when Withdraw button is pressed for Safe users', () => {
351368
// Arrange
352369
const mockWithdraw = jest.fn();
353370
mockUsePredictBalance.mockReturnValue({
@@ -369,6 +386,83 @@ describe('PredictBalance', () => {
369386
expect(mockWithdraw).toHaveBeenCalledTimes(1);
370387
expect(mockExecuteGuardedAction).not.toHaveBeenCalled();
371388
});
389+
390+
it('calls temporary unavailable handler instead of withdrawing for Deposit Wallet users', () => {
391+
// Arrange
392+
const mockWithdraw = jest.fn();
393+
const mockOnDepositWalletWithdrawPress = jest.fn();
394+
mockUsePredictBalance.mockReturnValue({
395+
data: 100,
396+
isLoading: false,
397+
});
398+
mockUsePredictAccountState.mockReturnValue({
399+
data: {
400+
address: '0x2222222222222222222222222222222222222222',
401+
isDeployed: true,
402+
walletType: 'deposit-wallet',
403+
},
404+
isLoading: false,
405+
});
406+
mockUsePredictWithdraw.mockReturnValue({
407+
withdraw: mockWithdraw,
408+
});
409+
410+
// Act
411+
const { getByText } = renderWithProvider(
412+
<PredictBalance
413+
onDepositWalletWithdrawPress={mockOnDepositWalletWithdrawPress}
414+
/>,
415+
{
416+
state: initialState,
417+
},
418+
);
419+
const withdrawButton = getByText(/Withdraw/i);
420+
fireEvent.press(withdrawButton);
421+
422+
// Assert
423+
expect(mockWithdraw).not.toHaveBeenCalled();
424+
expect(mockOnDepositWalletWithdrawPress).toHaveBeenCalledTimes(1);
425+
});
426+
427+
it('disables Withdraw while account state is unavailable', () => {
428+
// Arrange
429+
const mockWithdraw = jest.fn();
430+
const mockOnDepositWalletWithdrawPress = jest.fn();
431+
mockUsePredictBalance.mockReturnValue({
432+
data: 100,
433+
isLoading: false,
434+
});
435+
mockUsePredictAccountState.mockReturnValue({
436+
data: undefined,
437+
isLoading: true,
438+
});
439+
mockUsePredictWithdraw.mockReturnValue({
440+
withdraw: mockWithdraw,
441+
});
442+
443+
// Act
444+
const { getByTestId, UNSAFE_getByProps } = renderWithProvider(
445+
<PredictBalance
446+
onDepositWalletWithdrawPress={mockOnDepositWalletWithdrawPress}
447+
/>,
448+
{
449+
state: initialState,
450+
},
451+
);
452+
const withdrawButton = getByTestId(
453+
PREDICT_BALANCE_TEST_IDS.WITHDRAW_BUTTON,
454+
);
455+
fireEvent.press(withdrawButton);
456+
457+
// Assert
458+
const disabledWithdrawButton = UNSAFE_getByProps({
459+
testID: PREDICT_BALANCE_TEST_IDS.WITHDRAW_BUTTON,
460+
isDisabled: true,
461+
});
462+
expect(disabledWithdrawButton.props.isDisabled).toBe(true);
463+
expect(mockWithdraw).not.toHaveBeenCalled();
464+
expect(mockOnDepositWalletWithdrawPress).not.toHaveBeenCalled();
465+
});
372466
});
373467

374468
describe('balance refresh', () => {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export const PREDICT_BALANCE_TEST_IDS = {
22
SKELETON: 'predict-balance-card-skeleton',
33
CARD: 'predict-balance-card',
4+
WITHDRAW_BUTTON: 'predict-balance-withdraw-button',
5+
WITHDRAW_UNAVAILABLE_SHEET: 'predict-withdraw-unavailable-sheet',
6+
WITHDRAW_UNAVAILABLE_TITLE: 'predict-withdraw-unavailable-title',
7+
WITHDRAW_UNAVAILABLE_DESCRIPTION: 'predict-withdraw-unavailable-description',
8+
WITHDRAW_UNAVAILABLE_CLOSE_BUTTON:
9+
'predict-withdraw-unavailable-close-button',
10+
WITHDRAW_UNAVAILABLE_GOT_IT_BUTTON:
11+
'predict-withdraw-unavailable-got-it-button',
412
} as const;

app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,20 @@ import { formatPrice } from '../../utils/format';
3939
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
4040
import { PredictNavigationParamList } from '../../types/navigation';
4141
import { usePredictWithdraw } from '../../hooks/usePredictWithdraw';
42+
import { usePredictAccountState } from '../../hooks/usePredictAccountState';
4243
import { PredictEventValues } from '../../constants/eventNames';
4344
import { PREDICT_BALANCE_TEST_IDS } from './PredictBalance.testIds';
4445

4546
// This is a temporary component that will be removed when the deposit flow is fully implemented
4647
interface PredictBalanceProps {
4748
onLayout?: (height: number) => void;
49+
onDepositWalletWithdrawPress?: () => void;
4850
}
4951

50-
const PredictBalance: React.FC<PredictBalanceProps> = ({ onLayout }) => {
52+
const PredictBalance: React.FC<PredictBalanceProps> = ({
53+
onLayout,
54+
onDepositWalletWithdrawPress,
55+
}) => {
5156
const tw = useTailwind();
5257
const privacyMode = useSelector(selectPrivacyMode);
5358

@@ -64,6 +69,11 @@ const PredictBalance: React.FC<PredictBalanceProps> = ({ onLayout }) => {
6469

6570
const isAddingFunds = isDepositPending;
6671
const hasBalance = balance > 0;
72+
const { data: accountState } = usePredictAccountState({
73+
enabled: hasBalance,
74+
});
75+
const walletType = accountState?.walletType;
76+
const isWithdrawDisabled = hasBalance && !walletType;
6777

6878
useEffect(() => {
6979
if (!isDepositPending) {
@@ -87,8 +97,21 @@ const PredictBalance: React.FC<PredictBalanceProps> = ({ onLayout }) => {
8797
}, [deposit, executeGuardedAction]);
8898

8999
const handleWithdraw = useCallback(() => {
100+
// Do not proceed until account state is loaded; otherwise Deposit Wallet
101+
// users can bypass the temporary guard during the query window.
102+
if (!walletType) {
103+
return;
104+
}
105+
106+
// Temporary Deposit Wallet migration guard. Remove this branch and sheet
107+
// once Deposit Wallet withdrawals are implemented.
108+
if (walletType === 'deposit-wallet') {
109+
onDepositWalletWithdrawPress?.();
110+
return;
111+
}
112+
90113
withdraw();
91-
}, [withdraw]);
114+
}, [onDepositWalletWithdrawPress, walletType, withdraw]);
92115

93116
if (isLoading) {
94117
return (
@@ -202,6 +225,8 @@ const PredictBalance: React.FC<PredictBalanceProps> = ({ onLayout }) => {
202225
style={tw.style('flex-1')}
203226
label={strings('predict.deposit.withdraw')}
204227
onPress={handleWithdraw}
228+
isDisabled={isWithdrawDisabled}
229+
testID={PREDICT_BALANCE_TEST_IDS.WITHDRAW_BUTTON}
205230
/>
206231
)}
207232
</Box>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from 'react';
2+
import { fireEvent, render } from '@testing-library/react-native';
3+
import { strings } from '../../../../../../locales/i18n';
4+
import PredictWithdrawUnavailableSheet from './PredictWithdrawUnavailableSheet';
5+
import { PREDICT_BALANCE_TEST_IDS } from '../PredictBalance/PredictBalance.testIds';
6+
7+
let mockIsVisible = true;
8+
const mockCloseSheet = jest.fn();
9+
const mockGetRefHandlers = jest.fn(() => ({
10+
onOpenBottomSheet: jest.fn(),
11+
onCloseBottomSheet: jest.fn(),
12+
}));
13+
14+
jest.mock('../../hooks/usePredictBottomSheet', () => ({
15+
usePredictBottomSheet: () => ({
16+
sheetRef: { current: null },
17+
isVisible: mockIsVisible,
18+
closeSheet: mockCloseSheet,
19+
handleSheetClosed: jest.fn(),
20+
getRefHandlers: mockGetRefHandlers,
21+
}),
22+
}));
23+
24+
describe('PredictWithdrawUnavailableSheet', () => {
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
mockIsVisible = true;
28+
});
29+
30+
it('renders null when closed', () => {
31+
mockIsVisible = false;
32+
33+
const { queryByTestId } = render(<PredictWithdrawUnavailableSheet />);
34+
35+
expect(
36+
queryByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_SHEET),
37+
).toBeNull();
38+
});
39+
40+
it('renders the temporary Deposit Wallet withdraw unavailable note', () => {
41+
const { getByTestId } = render(<PredictWithdrawUnavailableSheet />);
42+
43+
expect(
44+
getByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_SHEET),
45+
).toBeOnTheScreen();
46+
expect(
47+
getByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_TITLE),
48+
).toHaveTextContent(strings('predict.withdraw.unavailable_title'));
49+
expect(
50+
getByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_DESCRIPTION),
51+
).toHaveTextContent(strings('predict.withdraw.unavailable_description'));
52+
expect(
53+
getByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_GOT_IT_BUTTON),
54+
).toHaveTextContent(strings('predict.withdraw.unavailable_got_it'));
55+
});
56+
57+
it('dismisses when Got it is pressed', () => {
58+
const { getByTestId } = render(<PredictWithdrawUnavailableSheet />);
59+
60+
fireEvent.press(
61+
getByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_GOT_IT_BUTTON),
62+
);
63+
64+
expect(mockCloseSheet).toHaveBeenCalledTimes(1);
65+
});
66+
67+
it('dismisses when close is pressed', () => {
68+
const { getByTestId } = render(<PredictWithdrawUnavailableSheet />);
69+
70+
fireEvent.press(
71+
getByTestId(PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_CLOSE_BUTTON),
72+
);
73+
74+
expect(mockCloseSheet).toHaveBeenCalledTimes(1);
75+
});
76+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {
2+
BottomSheet,
3+
BottomSheetFooter,
4+
BottomSheetHeader,
5+
Box,
6+
BoxAlignItems,
7+
Text,
8+
TextColor,
9+
TextVariant,
10+
} from '@metamask/design-system-react-native';
11+
import React, { forwardRef, useImperativeHandle } from 'react';
12+
import { strings } from '../../../../../../locales/i18n';
13+
import {
14+
usePredictBottomSheet,
15+
type PredictBottomSheetRef,
16+
} from '../../hooks/usePredictBottomSheet';
17+
import { PREDICT_BALANCE_TEST_IDS } from '../PredictBalance/PredictBalance.testIds';
18+
19+
export type PredictWithdrawUnavailableSheetRef = PredictBottomSheetRef;
20+
21+
/**
22+
* Temporary migration notice for Deposit Wallet users.
23+
* Remove this sheet and its trigger once Deposit Wallet withdrawals are supported.
24+
*/
25+
const PredictWithdrawUnavailableSheet = forwardRef<PredictBottomSheetRef>(
26+
(_props, ref) => {
27+
const {
28+
sheetRef,
29+
isVisible,
30+
closeSheet,
31+
handleSheetClosed,
32+
getRefHandlers,
33+
} = usePredictBottomSheet();
34+
35+
useImperativeHandle(ref, getRefHandlers, [getRefHandlers]);
36+
37+
if (!isVisible) {
38+
return null;
39+
}
40+
41+
return (
42+
<BottomSheet
43+
ref={sheetRef}
44+
isInteractable
45+
onClose={handleSheetClosed}
46+
testID={PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_SHEET}
47+
>
48+
<BottomSheetHeader
49+
onClose={closeSheet}
50+
closeButtonProps={{
51+
testID: PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_CLOSE_BUTTON,
52+
}}
53+
titleTestID={PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_TITLE}
54+
>
55+
{strings('predict.withdraw.unavailable_title')}
56+
</BottomSheetHeader>
57+
<Box
58+
alignItems={BoxAlignItems.Start}
59+
paddingHorizontal={4}
60+
paddingBottom={4}
61+
>
62+
<Text
63+
variant={TextVariant.BodyMd}
64+
color={TextColor.TextAlternative}
65+
testID={PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_DESCRIPTION}
66+
>
67+
{strings('predict.withdraw.unavailable_description')}
68+
</Text>
69+
</Box>
70+
<BottomSheetFooter
71+
twClassName="px-4"
72+
primaryButtonProps={{
73+
children: strings('predict.withdraw.unavailable_got_it'),
74+
onPress: closeSheet,
75+
testID: PREDICT_BALANCE_TEST_IDS.WITHDRAW_UNAVAILABLE_GOT_IT_BUTTON,
76+
}}
77+
/>
78+
</BottomSheet>
79+
);
80+
},
81+
);
82+
83+
PredictWithdrawUnavailableSheet.displayName = 'PredictWithdrawUnavailableSheet';
84+
85+
export default PredictWithdrawUnavailableSheet;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './PredictWithdrawUnavailableSheet';
2+
export type { PredictWithdrawUnavailableSheetRef } from './PredictWithdrawUnavailableSheet';

0 commit comments

Comments
 (0)