Skip to content

Commit 72cf037

Browse files
matthewwalsh0matallui
authored andcommitted
feat: cp-7.78.0 support deposit-wallet polymarket withdraw (#29953)
Adopts the new Polymarket deposit-wallet support landed in [@metamask/transaction-pay-controller@22.5.0](MetaMask/core#8754) so Polymarket users whose pUSD lives in a deposit wallet (a per-user batch contract on Polygon) can withdraw cross-chain through MetaMask Pay. Highlights: - Lets Polymarket deposit-wallet users withdraw cross-chain through MetaMask Pay. - Gated behind a new remote feature flag, with the existing "withdraw unavailable" sheet preserved when off. - Polishes Predict withdraw activity rendering. CHANGELOG entry: null <!-- Internal --> ```gherkin Feature: Polymarket deposit-wallet withdraw Scenario: deposit-wallet user with the flag on Given enableDepositWalletWithdraw is on And the user has a Polymarket deposit wallet with pUSD balance on Polygon When the user taps Withdraw on the Predict balance Then the standard Pay confirmation opens And confirming submits via the Polymarket strategy with no Polygon gas Scenario: deposit-wallet user with the flag off Given enableDepositWalletWithdraw is off When the user taps Withdraw on the Predict balance Then the existing "Withdraw unavailable" sheet is shown ``` <img width="300" alt="Activity" src="https://github.com/user-attachments/assets/13d5a0e9-a39d-4c0a-9fde-468c5a0a7743" /> - [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. - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes withdrawal behavior and MetaMask Pay transaction configuration for Polymarket `predictWithdraw`, including new controller callbacks and retry logic; mistakes could impact withdraw routing/fees for affected users. Gated by a remote feature flag, limiting blast radius. > > **Overview** > Enables Polymarket *deposit-wallet* users to run `predictWithdraw` through MetaMask Pay when the new `confirmations_pay_extended.enableDepositWalletWithdraw` flag is on; when off, the existing “withdraw unavailable” handling remains. > > Updates Predict/Pay plumbing for deposit-wallet withdraws: `PredictController.prepareWithdraw` now omits `gasFeeToken` for deposit-wallet accounts, `useTransactionPayPostQuote` skips `refundTo` and marks `isPolymarketDepositWallet`, and Transaction Pay initialization wires new Polymarket callbacks that can derive deposit-wallet addresses and submit deposit-wallet batches (with “wallet busy” retries + keyring signing support). > > Polishes confirmations activity rendering for `predictWithdraw` by adding a dedicated `predict_withdraw` title and treating it as a receive-summary type using the source token/network metadata. Tests are added/updated accordingly, and `@metamask/transaction-pay-controller` is bumped to `22.5.0`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 054697c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 3e598ba commit 72cf037

26 files changed

Lines changed: 774 additions & 43 deletions

File tree

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ const initialState = {
7676
},
7777
};
7878

79+
function stateWithDepositWalletWithdrawEnabled(enabled: boolean) {
80+
return {
81+
engine: {
82+
backgroundState: {
83+
...initialState.engine.backgroundState,
84+
RemoteFeatureFlagController: {
85+
...backgroundState.RemoteFeatureFlagController,
86+
remoteFeatureFlags: {
87+
...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags,
88+
confirmations_pay_extended: {
89+
enableDepositWalletWithdraw: enabled,
90+
},
91+
},
92+
},
93+
},
94+
},
95+
};
96+
}
97+
7998
describe('PredictBalance', () => {
8099
beforeEach(() => {
81100
jest.clearAllMocks();
@@ -387,6 +406,43 @@ describe('PredictBalance', () => {
387406
expect(mockExecuteGuardedAction).not.toHaveBeenCalled();
388407
});
389408

409+
it('calls withdraw for Deposit Wallet users when enableDepositWalletWithdraw flag is on', () => {
410+
// Arrange
411+
const mockWithdraw = jest.fn();
412+
const mockOnDepositWalletWithdrawPress = jest.fn();
413+
mockUsePredictBalance.mockReturnValue({
414+
data: 100,
415+
isLoading: false,
416+
});
417+
mockUsePredictAccountState.mockReturnValue({
418+
data: {
419+
address: '0x2222222222222222222222222222222222222222',
420+
isDeployed: true,
421+
walletType: 'deposit-wallet',
422+
},
423+
isLoading: false,
424+
});
425+
mockUsePredictWithdraw.mockReturnValue({
426+
withdraw: mockWithdraw,
427+
});
428+
429+
// Act
430+
const { getByText } = renderWithProvider(
431+
<PredictBalance
432+
onDepositWalletWithdrawPress={mockOnDepositWalletWithdrawPress}
433+
/>,
434+
{
435+
state: stateWithDepositWalletWithdrawEnabled(true),
436+
},
437+
);
438+
const withdrawButton = getByText(/Withdraw/i);
439+
fireEvent.press(withdrawButton);
440+
441+
// Assert
442+
expect(mockWithdraw).toHaveBeenCalledTimes(1);
443+
expect(mockOnDepositWalletWithdrawPress).not.toHaveBeenCalled();
444+
});
445+
390446
it('calls temporary unavailable handler instead of withdrawing for Deposit Wallet users', () => {
391447
// Arrange
392448
const mockWithdraw = jest.fn();

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { PredictNavigationParamList } from '../../types/navigation';
4141
import { usePredictWithdraw } from '../../hooks/usePredictWithdraw';
4242
import { usePredictAccountState } from '../../hooks/usePredictAccountState';
4343
import { PredictEventValues } from '../../constants/eventNames';
44+
import { selectMetaMaskPayFlags } from '../../../../../selectors/featureFlagController/confirmations';
4445
import { PREDICT_BALANCE_TEST_IDS } from './PredictBalance.testIds';
4546

4647
// This is a temporary component that will be removed when the deposit flow is fully implemented
@@ -55,6 +56,7 @@ const PredictBalance: React.FC<PredictBalanceProps> = ({
5556
}) => {
5657
const tw = useTailwind();
5758
const privacyMode = useSelector(selectPrivacyMode);
59+
const { enableDepositWalletWithdraw } = useSelector(selectMetaMaskPayFlags);
5860

5961
const navigation =
6062
useNavigation<NavigationProp<PredictNavigationParamList>>();
@@ -103,15 +105,18 @@ const PredictBalance: React.FC<PredictBalanceProps> = ({
103105
return;
104106
}
105107

106-
// Temporary Deposit Wallet migration guard. Remove this branch and sheet
107-
// once Deposit Wallet withdrawals are implemented.
108-
if (walletType === 'deposit-wallet') {
108+
if (walletType === 'deposit-wallet' && !enableDepositWalletWithdraw) {
109109
onDepositWalletWithdrawPress?.();
110110
return;
111111
}
112112

113113
withdraw();
114-
}, [onDepositWalletWithdrawPress, walletType, withdraw]);
114+
}, [
115+
enableDepositWalletWithdraw,
116+
onDepositWalletWithdrawPress,
117+
walletType,
118+
withdraw,
119+
]);
115120

116121
if (isLoading) {
117122
return (

app/components/UI/Predict/controllers/PredictController.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,11 @@ describe('PredictController', () => {
288288
syncDepositWalletBalanceAllowanceForDepositTransaction: jest.fn(),
289289
} as unknown as jest.Mocked<PolymarketProvider>;
290290

291+
mockPolymarketProvider.getAccountState.mockResolvedValue({
292+
address: '0xProxyAddress' as `0x${string}`,
293+
isDeployed: true,
294+
walletType: 'safe' as const,
295+
});
291296
mockPolymarketProvider.beforePublishDepositWalletDeposit.mockResolvedValue(
292297
true,
293298
);
@@ -6076,6 +6081,54 @@ describe('PredictController', () => {
60766081
});
60776082
});
60786083

6084+
it('sets gasFeeToken when account walletType is not deposit-wallet', async () => {
6085+
mockPolymarketProvider.prepareWithdraw.mockResolvedValue(
6086+
mockWithdrawResponse,
6087+
);
6088+
mockPolymarketProvider.getAccountState.mockResolvedValue({
6089+
address: '0xProxyAddress' as `0x${string}`,
6090+
isDeployed: true,
6091+
walletType: 'safe' as const,
6092+
});
6093+
(addTransactionBatch as jest.Mock).mockResolvedValue({
6094+
batchId: 'batch-safe',
6095+
});
6096+
6097+
await withController(async ({ controller }) => {
6098+
await controller.prepareWithdraw({});
6099+
6100+
expect(addTransactionBatch).toHaveBeenCalledWith(
6101+
expect.objectContaining({
6102+
gasFeeToken: MATIC_CONTRACTS_V2.collateral,
6103+
}),
6104+
);
6105+
});
6106+
});
6107+
6108+
it('omits gasFeeToken when account walletType is deposit-wallet', async () => {
6109+
mockPolymarketProvider.prepareWithdraw.mockResolvedValue(
6110+
mockWithdrawResponse,
6111+
);
6112+
mockPolymarketProvider.getAccountState.mockResolvedValue({
6113+
address: '0xDepositWalletAddress' as `0x${string}`,
6114+
isDeployed: true,
6115+
walletType: 'deposit-wallet' as const,
6116+
});
6117+
(addTransactionBatch as jest.Mock).mockResolvedValue({
6118+
batchId: 'batch-deposit',
6119+
});
6120+
6121+
await withController(async ({ controller }) => {
6122+
await controller.prepareWithdraw({});
6123+
6124+
expect(addTransactionBatch).toHaveBeenCalledWith(
6125+
expect.objectContaining({
6126+
gasFeeToken: undefined,
6127+
}),
6128+
);
6129+
});
6130+
});
6131+
60796132
it('update transaction ID when batch ID is returned', async () => {
60806133
const mockBatchId = 'tx-batch-update';
60816134

app/components/UI/Predict/controllers/PredictController.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2595,6 +2595,16 @@ export class PredictController extends BaseController<
25952595
signer,
25962596
});
25972597

2598+
const accountState = await provider.getAccountState({
2599+
ownerAddress: signer.address,
2600+
});
2601+
2602+
const isDepositWallet = accountState.walletType === 'deposit-wallet';
2603+
2604+
const gasFeeToken = isDepositWallet
2605+
? undefined
2606+
: (MATIC_CONTRACTS_V2.collateral as Hex);
2607+
25982608
this.update((state) => {
25992609
state.withdrawTransaction = {
26002610
chainId: hexToNumber(chainId),
@@ -2616,9 +2626,8 @@ export class PredictController extends BaseController<
26162626
disableHook: true,
26172627
disableSequential: true,
26182628
requireApproval: true,
2619-
// Temporarily breaking abstraction, can instead be abstracted via provider.
2620-
gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex,
26212629
transactions: [transaction],
2630+
gasFeeToken,
26222631
});
26232632

26242633
this.update((state) => {

app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridg
1313
import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useBridgeTxHistoryData';
1414
import { useTokenAmount } from '../../../hooks/useTokenAmount';
1515
import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails';
16+
import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance';
1617
import { ReceiveSummaryLine } from './receive-summary-line';
1718

1819
jest.mock('../../../../../UI/Bridge/hooks/useMultichainBlockExplorerTxUrl');
@@ -21,6 +22,7 @@ jest.mock('../../../../../../selectors/bridgeStatusController');
2122
jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData');
2223
jest.mock('../../../hooks/useTokenAmount');
2324
jest.mock('../../../hooks/activity/useTransactionDetails');
25+
jest.mock('../../../hooks/tokens/useTokenWithBalance');
2426

2527
jest.mock('@react-navigation/native', () => ({
2628
...jest.requireActual('@react-navigation/native'),
@@ -161,4 +163,36 @@ describe('ReceiveSummaryLine', () => {
161163
),
162164
).toBeDefined();
163165
});
166+
167+
it('renders predict withdraw title using source token symbol and source network', () => {
168+
useNetworkNameMock.mockImplementation((chainId?: Hex) =>
169+
chainId === '0x1' ? 'Ethereum' : 'Polygon',
170+
);
171+
jest
172+
.mocked(useTokenWithBalance)
173+
.mockReturnValue({ symbol: 'USDC' } as ReturnType<
174+
typeof useTokenWithBalance
175+
>);
176+
177+
const { getByText } = render({
178+
id: 'tx-id',
179+
chainId: '0x89' as Hex,
180+
hash: '0x123',
181+
submittedTime: 1755719285723,
182+
type: TransactionType.predictWithdraw,
183+
metamaskPay: {
184+
chainId: '0x1' as Hex,
185+
tokenAddress: '0xabc' as Hex,
186+
},
187+
} as Partial<TransactionMeta>);
188+
189+
expect(
190+
getByText(
191+
strings('transaction_details.summary_title.bridge_receive', {
192+
targetSymbol: 'USDC',
193+
targetChain: 'Ethereum',
194+
}),
195+
),
196+
).toBeDefined();
197+
});
164198
});

app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { hasTransactionType } from '../../../utils/transaction';
1010
import { useNetworkName } from '../../../hooks/useNetworkName';
1111
import { POLYGON_PUSD } from '../../../constants/predict';
1212
import { TransactionSummaryLine } from './transaction-summary-line';
13+
import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance';
1314

1415
const HYPERLIQUID_EXPLORER_URL = 'https://app.hyperliquid.xyz/explorer/tx';
1516
const HYPERLIQUID_EXPLORER_NAME = 'Hyperliquid';
@@ -19,7 +20,10 @@ export function ReceiveSummaryLine({
1920
}: {
2021
transactionMeta: TransactionMeta;
2122
}) {
22-
const { chainId } = transactionMeta;
23+
const { chainId: targetChainId, metamaskPay } = transactionMeta;
24+
const sourceChainId = metamaskPay?.chainId;
25+
const sourceTokenAddress = metamaskPay?.tokenAddress;
26+
2327
const isPerpsDeposit = hasTransactionType(transactionMeta, [
2428
TransactionType.perpsDeposit,
2529
]);
@@ -28,25 +32,39 @@ export function ReceiveSummaryLine({
2832
TransactionType.predictDeposit,
2933
]);
3034

31-
const networkName = useNetworkName(chainId);
35+
const isPredictWithdraw = hasTransactionType(transactionMeta, [
36+
TransactionType.predictWithdraw,
37+
]);
38+
39+
const targetNetworkName = useNetworkName(targetChainId);
40+
const sourceNetworkName = useNetworkName(sourceChainId ?? '0x0');
41+
42+
const sourceToken = useTokenWithBalance(
43+
sourceTokenAddress ?? '0x0',
44+
sourceChainId ?? '0x0',
45+
);
3246

3347
let targetSymbol = 'mUSD';
34-
let targetNetworkName: string | undefined = networkName;
35-
let receiveChainId: Hex = chainId;
48+
let finalTargetNetworkName: string | undefined = targetNetworkName;
49+
let receiveChainId: Hex = targetChainId;
3650

3751
if (isPerpsDeposit) {
3852
targetSymbol = 'USDC';
39-
targetNetworkName = 'Hyperliquid';
53+
finalTargetNetworkName = 'Hyperliquid';
4054
receiveChainId = CHAIN_IDS.ARBITRUM;
4155
} else if (isPredictDeposit) {
4256
targetSymbol = POLYGON_PUSD.symbol;
57+
} else if (isPredictWithdraw) {
58+
targetSymbol = sourceToken?.symbol ?? 'Unknown';
59+
finalTargetNetworkName = sourceNetworkName;
60+
receiveChainId = sourceChainId ?? '0x0';
4361
}
4462

4563
const title =
46-
targetSymbol && targetNetworkName
64+
targetSymbol && finalTargetNetworkName
4765
? strings('transaction_details.summary_title.bridge_receive', {
4866
targetSymbol,
49-
targetChain: targetNetworkName,
67+
targetChain: finalTargetNetworkName,
5068
})
5169
: strings('transaction_details.summary_title.bridge_receive_loading');
5270

app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import React from 'react';
22
import { fireEvent } from '@testing-library/react-native';
3-
import { TransactionMeta } from '@metamask/transaction-controller';
3+
import {
4+
TransactionMeta,
5+
TransactionType,
6+
} from '@metamask/transaction-controller';
47
import { Hex } from '@metamask/utils';
58
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
69
import { strings } from '../../../../../../../locales/i18n';
@@ -31,19 +34,19 @@ jest.mock('@react-navigation/native', () => ({
3134
}),
3235
}));
3336

34-
function render() {
37+
function render(parentTransaction?: Partial<TransactionMeta>) {
3538
return renderWithProvider(
3639
<SourceHashSummaryLine
3740
parentTransaction={
38-
{
41+
(parentTransaction ?? {
3942
id: 'parent-id',
4043
chainId: '0x1',
4144
submittedTime: 1755719285723,
4245
metamaskPay: {
4346
tokenAddress: '0x123',
4447
chainId: '0x1',
4548
},
46-
} as unknown as TransactionMeta
49+
}) as unknown as TransactionMeta
4750
}
4851
sourceHash={'0xabc' as Hex}
4952
/>,
@@ -118,4 +121,30 @@ describe('SourceHashSummaryLine', () => {
118121
},
119122
});
120123
});
124+
125+
it('renders predict-withdraw title with pUSD and target network', () => {
126+
useNetworkNameMock.mockImplementation((chainId?: Hex) =>
127+
chainId === '0x89' ? 'Polygon' : 'Ethereum',
128+
);
129+
130+
const { getByText } = render({
131+
id: 'parent-id',
132+
chainId: '0x89' as Hex,
133+
submittedTime: 1755719285723,
134+
type: TransactionType.predictWithdraw,
135+
metamaskPay: {
136+
tokenAddress: '0x123' as Hex,
137+
chainId: '0x1' as Hex,
138+
},
139+
} as Partial<TransactionMeta>);
140+
141+
expect(
142+
getByText(
143+
strings('transaction_details.summary_title.predict_withdraw', {
144+
sourceSymbol: 'pUSD',
145+
sourceChain: 'Polygon',
146+
}),
147+
),
148+
).toBeDefined();
149+
});
121150
});

0 commit comments

Comments
 (0)