From 31b133e7288bae1d03d44bb3a46f59e5f700335a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 15 May 2026 13:23:54 +0000 Subject: [PATCH] chore(runway): cherry-pick feat: cp-7.78.0 support deposit-wallet polymarket withdraw (#29953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adopts the new Polymarket deposit-wallet support landed in [@metamask/transaction-pay-controller@22.5.0](https://github.com/MetaMask/core/pull/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** CHANGELOG entry: null ## **Related issues** ## **Manual testing steps** ```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 ``` ## **Screenshots/Recordings** ### **Before** ### **After** Activity ## **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) - [ ] 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 ## **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. --- > [!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`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 054697c21ec5e65a5069e6199f6e7ef902e4649a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PredictBalance/PredictBalance.test.tsx | 56 +++++ .../PredictBalance/PredictBalance.tsx | 13 +- .../controllers/PredictController.test.ts | 53 +++++ .../Predict/controllers/PredictController.ts | 13 +- .../receive-summary-line.test.tsx | 34 +++ .../receive-summary-line.tsx | 32 ++- .../source-hash-summary-line.test.tsx | 37 +++- .../source-hash-summary-line.tsx | 48 ++-- .../transaction-details-summary.tsx | 1 + .../transaction-details.test.tsx | 5 +- .../transaction-details.tsx | 1 + .../custom-amount-info.test.tsx | 6 + .../pay/useTransactionPayPostQuote.test.ts | 95 ++++++++ .../hooks/pay/useTransactionPayPostQuote.ts | 25 ++- .../transaction-controller-init.test.ts | 3 + .../polymarket-callbacks.test.ts | 205 ++++++++++++++++++ .../polymarket-callbacks.ts | 101 +++++++++ .../transaction-pay-controller-init.test.ts | 18 ++ .../transaction-pay-controller-init.ts | 2 + .../transaction-controller-messenger.ts | 6 + .../transaction-pay-controller-messenger.ts | 12 +- .../confirmations/index.test.ts | 21 ++ .../confirmations/index.ts | 17 +- locales/languages/en.json | 1 + package.json | 2 +- yarn.lock | 10 +- 26 files changed, 774 insertions(+), 43 deletions(-) create mode 100644 app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.test.ts create mode 100644 app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.ts diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx index 6729e67f2de..d72ba723e55 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx @@ -76,6 +76,25 @@ const initialState = { }, }; +function stateWithDepositWalletWithdrawEnabled(enabled: boolean) { + return { + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags, + confirmations_pay_extended: { + enableDepositWalletWithdraw: enabled, + }, + }, + }, + }, + }, + }; +} + describe('PredictBalance', () => { beforeEach(() => { jest.clearAllMocks(); @@ -387,6 +406,43 @@ describe('PredictBalance', () => { expect(mockExecuteGuardedAction).not.toHaveBeenCalled(); }); + it('calls withdraw for Deposit Wallet users when enableDepositWalletWithdraw flag is on', () => { + // Arrange + const mockWithdraw = jest.fn(); + const mockOnDepositWalletWithdrawPress = jest.fn(); + mockUsePredictBalance.mockReturnValue({ + data: 100, + isLoading: false, + }); + mockUsePredictAccountState.mockReturnValue({ + data: { + address: '0x2222222222222222222222222222222222222222', + isDeployed: true, + walletType: 'deposit-wallet', + }, + isLoading: false, + }); + mockUsePredictWithdraw.mockReturnValue({ + withdraw: mockWithdraw, + }); + + // Act + const { getByText } = renderWithProvider( + , + { + state: stateWithDepositWalletWithdrawEnabled(true), + }, + ); + const withdrawButton = getByText(/Withdraw/i); + fireEvent.press(withdrawButton); + + // Assert + expect(mockWithdraw).toHaveBeenCalledTimes(1); + expect(mockOnDepositWalletWithdrawPress).not.toHaveBeenCalled(); + }); + it('calls temporary unavailable handler instead of withdrawing for Deposit Wallet users', () => { // Arrange const mockWithdraw = jest.fn(); diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx index bc68f1121a7..bbb71ca05d2 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx @@ -41,6 +41,7 @@ import { PredictNavigationParamList } from '../../types/navigation'; import { usePredictWithdraw } from '../../hooks/usePredictWithdraw'; import { usePredictAccountState } from '../../hooks/usePredictAccountState'; import { PredictEventValues } from '../../constants/eventNames'; +import { selectMetaMaskPayFlags } from '../../../../../selectors/featureFlagController/confirmations'; import { PREDICT_BALANCE_TEST_IDS } from './PredictBalance.testIds'; // This is a temporary component that will be removed when the deposit flow is fully implemented @@ -55,6 +56,7 @@ const PredictBalance: React.FC = ({ }) => { const tw = useTailwind(); const privacyMode = useSelector(selectPrivacyMode); + const { enableDepositWalletWithdraw } = useSelector(selectMetaMaskPayFlags); const navigation = useNavigation>(); @@ -103,15 +105,18 @@ const PredictBalance: React.FC = ({ return; } - // Temporary Deposit Wallet migration guard. Remove this branch and sheet - // once Deposit Wallet withdrawals are implemented. - if (walletType === 'deposit-wallet') { + if (walletType === 'deposit-wallet' && !enableDepositWalletWithdraw) { onDepositWalletWithdrawPress?.(); return; } withdraw(); - }, [onDepositWalletWithdrawPress, walletType, withdraw]); + }, [ + enableDepositWalletWithdraw, + onDepositWalletWithdrawPress, + walletType, + withdraw, + ]); if (isLoading) { return ( diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index e26ec86de63..2c3db2effb9 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -288,6 +288,11 @@ describe('PredictController', () => { syncDepositWalletBalanceAllowanceForDepositTransaction: jest.fn(), } as unknown as jest.Mocked; + mockPolymarketProvider.getAccountState.mockResolvedValue({ + address: '0xProxyAddress' as `0x${string}`, + isDeployed: true, + walletType: 'safe' as const, + }); mockPolymarketProvider.beforePublishDepositWalletDeposit.mockResolvedValue( true, ); @@ -6076,6 +6081,54 @@ describe('PredictController', () => { }); }); + it('sets gasFeeToken when account walletType is not deposit-wallet', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + mockPolymarketProvider.getAccountState.mockResolvedValue({ + address: '0xProxyAddress' as `0x${string}`, + isDeployed: true, + walletType: 'safe' as const, + }); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-safe', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: MATIC_CONTRACTS_V2.collateral, + }), + ); + }); + }); + + it('omits gasFeeToken when account walletType is deposit-wallet', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + mockPolymarketProvider.getAccountState.mockResolvedValue({ + address: '0xDepositWalletAddress' as `0x${string}`, + isDeployed: true, + walletType: 'deposit-wallet' as const, + }); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-deposit', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeToken: undefined, + }), + ); + }); + }); + it('update transaction ID when batch ID is returned', async () => { const mockBatchId = 'tx-batch-update'; diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index dff17733a08..cb7c140a2fc 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -2595,6 +2595,16 @@ export class PredictController extends BaseController< signer, }); + const accountState = await provider.getAccountState({ + ownerAddress: signer.address, + }); + + const isDepositWallet = accountState.walletType === 'deposit-wallet'; + + const gasFeeToken = isDepositWallet + ? undefined + : (MATIC_CONTRACTS_V2.collateral as Hex); + this.update((state) => { state.withdrawTransaction = { chainId: hexToNumber(chainId), @@ -2616,9 +2626,8 @@ export class PredictController extends BaseController< disableHook: true, disableSequential: true, requireApproval: true, - // Temporarily breaking abstraction, can instead be abstracted via provider. - gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex, transactions: [transaction], + gasFeeToken, }); this.update((state) => { diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.test.tsx index 129a93dfa89..83d293d4385 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.test.tsx @@ -13,6 +13,7 @@ import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridg import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useBridgeTxHistoryData'; import { useTokenAmount } from '../../../hooks/useTokenAmount'; import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails'; +import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; import { ReceiveSummaryLine } from './receive-summary-line'; jest.mock('../../../../../UI/Bridge/hooks/useMultichainBlockExplorerTxUrl'); @@ -21,6 +22,7 @@ jest.mock('../../../../../../selectors/bridgeStatusController'); jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData'); jest.mock('../../../hooks/useTokenAmount'); jest.mock('../../../hooks/activity/useTransactionDetails'); +jest.mock('../../../hooks/tokens/useTokenWithBalance'); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -161,4 +163,36 @@ describe('ReceiveSummaryLine', () => { ), ).toBeDefined(); }); + + it('renders predict withdraw title using source token symbol and source network', () => { + useNetworkNameMock.mockImplementation((chainId?: Hex) => + chainId === '0x1' ? 'Ethereum' : 'Polygon', + ); + jest + .mocked(useTokenWithBalance) + .mockReturnValue({ symbol: 'USDC' } as ReturnType< + typeof useTokenWithBalance + >); + + const { getByText } = render({ + id: 'tx-id', + chainId: '0x89' as Hex, + hash: '0x123', + submittedTime: 1755719285723, + type: TransactionType.predictWithdraw, + metamaskPay: { + chainId: '0x1' as Hex, + tokenAddress: '0xabc' as Hex, + }, + } as Partial); + + expect( + getByText( + strings('transaction_details.summary_title.bridge_receive', { + targetSymbol: 'USDC', + targetChain: 'Ethereum', + }), + ), + ).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.tsx index 8aacc51634c..11a12001461 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/receive-summary-line.tsx @@ -10,6 +10,7 @@ import { hasTransactionType } from '../../../utils/transaction'; import { useNetworkName } from '../../../hooks/useNetworkName'; import { POLYGON_PUSD } from '../../../constants/predict'; import { TransactionSummaryLine } from './transaction-summary-line'; +import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; const HYPERLIQUID_EXPLORER_URL = 'https://app.hyperliquid.xyz/explorer/tx'; const HYPERLIQUID_EXPLORER_NAME = 'Hyperliquid'; @@ -19,7 +20,10 @@ export function ReceiveSummaryLine({ }: { transactionMeta: TransactionMeta; }) { - const { chainId } = transactionMeta; + const { chainId: targetChainId, metamaskPay } = transactionMeta; + const sourceChainId = metamaskPay?.chainId; + const sourceTokenAddress = metamaskPay?.tokenAddress; + const isPerpsDeposit = hasTransactionType(transactionMeta, [ TransactionType.perpsDeposit, ]); @@ -28,25 +32,39 @@ export function ReceiveSummaryLine({ TransactionType.predictDeposit, ]); - const networkName = useNetworkName(chainId); + const isPredictWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.predictWithdraw, + ]); + + const targetNetworkName = useNetworkName(targetChainId); + const sourceNetworkName = useNetworkName(sourceChainId ?? '0x0'); + + const sourceToken = useTokenWithBalance( + sourceTokenAddress ?? '0x0', + sourceChainId ?? '0x0', + ); let targetSymbol = 'mUSD'; - let targetNetworkName: string | undefined = networkName; - let receiveChainId: Hex = chainId; + let finalTargetNetworkName: string | undefined = targetNetworkName; + let receiveChainId: Hex = targetChainId; if (isPerpsDeposit) { targetSymbol = 'USDC'; - targetNetworkName = 'Hyperliquid'; + finalTargetNetworkName = 'Hyperliquid'; receiveChainId = CHAIN_IDS.ARBITRUM; } else if (isPredictDeposit) { targetSymbol = POLYGON_PUSD.symbol; + } else if (isPredictWithdraw) { + targetSymbol = sourceToken?.symbol ?? 'Unknown'; + finalTargetNetworkName = sourceNetworkName; + receiveChainId = sourceChainId ?? '0x0'; } const title = - targetSymbol && targetNetworkName + targetSymbol && finalTargetNetworkName ? strings('transaction_details.summary_title.bridge_receive', { targetSymbol, - targetChain: targetNetworkName, + targetChain: finalTargetNetworkName, }) : strings('transaction_details.summary_title.bridge_receive_loading'); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx index 696513c3528..a9d215105b9 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { strings } from '../../../../../../../locales/i18n'; @@ -31,11 +34,11 @@ jest.mock('@react-navigation/native', () => ({ }), })); -function render() { +function render(parentTransaction?: Partial) { return renderWithProvider( , @@ -118,4 +121,30 @@ describe('SourceHashSummaryLine', () => { }, }); }); + + it('renders predict-withdraw title with pUSD and target network', () => { + useNetworkNameMock.mockImplementation((chainId?: Hex) => + chainId === '0x89' ? 'Polygon' : 'Ethereum', + ); + + const { getByText } = render({ + id: 'parent-id', + chainId: '0x89' as Hex, + submittedTime: 1755719285723, + type: TransactionType.predictWithdraw, + metamaskPay: { + tokenAddress: '0x123' as Hex, + chainId: '0x1' as Hex, + }, + } as Partial); + + expect( + getByText( + strings('transaction_details.summary_title.predict_withdraw', { + sourceSymbol: 'pUSD', + sourceChain: 'Polygon', + }), + ), + ).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx index 87733f43eab..e5e62fe251b 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx @@ -1,10 +1,15 @@ import React from 'react'; -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; import { useNetworkName } from '../../../hooks/useNetworkName'; import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; import { TransactionSummaryLine } from './transaction-summary-line'; +import { hasTransactionType } from '../../../utils/transaction'; +import { POLYGON_PUSD } from '../../../constants/predict'; export function SourceHashSummaryLine({ parentTransaction, @@ -13,24 +18,39 @@ export function SourceHashSummaryLine({ parentTransaction: TransactionMeta; sourceHash: Hex; }) { - const tokenAddress = parentTransaction.metamaskPay?.tokenAddress; - const tokenChainId = parentTransaction.metamaskPay?.chainId; + const { chainId: targetChainId, metamaskPay } = parentTransaction; + const sourceTokenAddress = metamaskPay?.tokenAddress; + const sourceTokenChainId = metamaskPay?.chainId; const sourceToken = useTokenWithBalance( - tokenAddress ?? '0x0', - tokenChainId ?? '0x0', + sourceTokenAddress ?? '0x0', + sourceTokenChainId ?? '0x0', ); - const sourceNetworkName = useNetworkName(tokenChainId); - const chainId = tokenChainId ?? parentTransaction.chainId; + const sourceNetworkName = useNetworkName(sourceTokenChainId); + const targetNetworkName = useNetworkName(targetChainId); - const title = - sourceToken?.symbol && sourceNetworkName - ? strings('transaction_details.summary_title.bridge_send', { - sourceSymbol: sourceToken.symbol, - sourceChain: sourceNetworkName, - }) - : strings('transaction_details.summary_title.bridge_send_loading'); + const isPredictWithdraw = hasTransactionType(parentTransaction, [ + TransactionType.predictWithdraw, + ]); + + const chainId = isPredictWithdraw ? targetChainId : sourceTokenChainId; + + let title = strings('transaction_details.summary_title.bridge_send_loading'); + + if (sourceToken?.symbol && sourceNetworkName) { + title = strings('transaction_details.summary_title.bridge_send', { + sourceSymbol: sourceToken.symbol, + sourceChain: sourceNetworkName, + }); + + if (isPredictWithdraw) { + title = strings('transaction_details.summary_title.predict_withdraw', { + sourceSymbol: POLYGON_PUSD.symbol, + sourceChain: targetNetworkName, + }); + } + } return ( ; diff --git a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx index 1847378f80b..0941e6d8583 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx @@ -360,12 +360,13 @@ describe('TransactionDetails', () => { TransactionType.moneyAccountWithdraw, TransactionType.perpsDeposit, TransactionType.predictDeposit, + TransactionType.predictWithdraw, ])('includes %s', (type) => { expect(SUMMARY_SECTION_TYPES).toContain(type); }); - it('contains exactly 6 transaction types', () => { - expect(SUMMARY_SECTION_TYPES).toHaveLength(6); + it('contains exactly 7 transaction types', () => { + expect(SUMMARY_SECTION_TYPES).toHaveLength(7); }); }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx index 4fbe94fef1e..1901102cbc7 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx @@ -32,6 +32,7 @@ export const SUMMARY_SECTION_TYPES = [ TransactionType.moneyAccountWithdraw, TransactionType.perpsDeposit, TransactionType.predictDeposit, + TransactionType.predictWithdraw, ]; export function TransactionDetails() { diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 33ac381a605..f933a750ce3 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -54,6 +54,12 @@ jest.mock('../../../context/alert-system-context'); jest.mock('../../../hooks/transactions/useTransactionCustomAmountAlerts'); jest.mock('../../../hooks/pay/useTransactionPayMetrics'); jest.mock('../../../hooks/send/useAccountTokens'); +jest.mock('../../../../../UI/Predict/hooks/usePredictAccountState', () => ({ + usePredictAccountState: () => ({ + data: undefined, + isLoading: false, + }), +})); jest.mock('../../../hooks/pay/useTransactionPayAvailableTokens'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/pay/useTransactionPayHasSourceAmount'); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts index 03f6313da56..a740b930b21 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts @@ -6,6 +6,7 @@ import { useTransactionMetadataRequest } from '../transactions/useTransactionMet import { useTransactionPayWithdraw } from './useTransactionPayWithdraw'; import Engine from '../../../../../core/Engine'; import { computeProxyAddress } from '../../../../UI/Predict/providers/polymarket/safe/utils'; +import { usePredictAccountState } from '../../../../UI/Predict/hooks/usePredictAccountState'; jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('./useTransactionPayWithdraw'); @@ -19,6 +20,7 @@ jest.mock('../../../../../core/Engine', () => ({ jest.mock('../../../../UI/Predict/providers/polymarket/safe/utils', () => ({ computeProxyAddress: jest.fn(), })); +jest.mock('../../../../UI/Predict/hooks/usePredictAccountState'); const TRANSACTION_ID_MOCK = 'transaction-123'; const FROM_MOCK = '0x1234567890123456789012345678901234567890' as Hex; @@ -32,11 +34,24 @@ describe('useTransactionPayPostQuote', () => { const setTransactionConfigMock = jest.mocked( Engine.context.TransactionPayController.setTransactionConfig, ); + const usePredictAccountStateMock = jest.mocked(usePredictAccountState); const computeProxyAddressMock = jest.mocked(computeProxyAddress); + function mockAccountState(walletType: 'safe' | 'deposit-wallet'): void { + usePredictAccountStateMock.mockReturnValue({ + data: { + address: '0xProxyAddress', + isDeployed: true, + walletType, + }, + isLoading: false, + } as never); + } + beforeEach(() => { jest.clearAllMocks(); computeProxyAddressMock.mockReturnValue(PROXY_ADDRESS_MOCK); + mockAccountState('safe'); useTransactionPayWithdrawMock.mockReturnValue({ isWithdraw: false, canSelectWithdrawToken: false, @@ -263,4 +278,84 @@ describe('useTransactionPayPostQuote', () => { expect(config.isHyperliquidSource).toBeUndefined(); expect(computeProxyAddressMock).not.toHaveBeenCalled(); }); + + describe('Polymarket deposit-wallet predictWithdraw', () => { + beforeEach(() => { + setTransactionConfigMock.mockReset(); + useTransactionMetadataRequestMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { from: FROM_MOCK }, + type: TransactionType.predictWithdraw, + } as never); + useTransactionPayWithdrawMock.mockReturnValue({ + isWithdraw: true, + canSelectWithdrawToken: true, + }); + }); + + it('flags transaction and skips refundTo when walletType is deposit-wallet', () => { + mockAccountState('deposit-wallet'); + + renderHook(() => useTransactionPayPostQuote()); + + expect(setTransactionConfigMock).toHaveBeenCalledTimes(1); + + const callback = setTransactionConfigMock.mock.calls[0][1]; + const config = {} as { + isPostQuote?: boolean; + isPolymarketDepositWallet?: boolean; + refundTo?: Hex; + }; + callback(config); + + expect(config.isPostQuote).toBe(true); + expect(config.isPolymarketDepositWallet).toBe(true); + expect(config.refundTo).toBeUndefined(); + expect(computeProxyAddressMock).not.toHaveBeenCalled(); + }); + + it('does not set deposit-wallet flag when walletType is safe', () => { + mockAccountState('safe'); + + renderHook(() => useTransactionPayPostQuote()); + + expect(setTransactionConfigMock).toHaveBeenCalledTimes(1); + + const callback = setTransactionConfigMock.mock.calls[0][1]; + const config = {} as { + isPostQuote?: boolean; + isPolymarketDepositWallet?: boolean; + refundTo?: Hex; + }; + callback(config); + + expect(config.isPolymarketDepositWallet).toBeUndefined(); + }); + + it('defers setTransactionConfig until predict account state resolves', () => { + usePredictAccountStateMock.mockReturnValue({ + data: undefined, + isLoading: true, + } as never); + + renderHook(() => useTransactionPayPostQuote()); + + expect(setTransactionConfigMock).not.toHaveBeenCalled(); + }); + + it('does not resolve account state for non-predictWithdraw flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { from: FROM_MOCK }, + type: TransactionType.perpsWithdraw, + } as never); + + renderHook(() => useTransactionPayPostQuote()); + + expect(usePredictAccountStateMock).toHaveBeenCalledWith({ + enabled: false, + }); + expect(setTransactionConfigMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts index fd8a9ebe0f6..5eb34b2c678 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts @@ -6,6 +6,7 @@ import { useTransactionPayWithdraw } from './useTransactionPayWithdraw'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { computeProxyAddress } from '../../../../UI/Predict/providers/polymarket/safe/utils'; import { hasTransactionType } from '../../utils/transaction'; +import { usePredictAccountState } from '../../../../UI/Predict/hooks/usePredictAccountState'; const log = createProjectLogger('transaction-pay-post-quote'); @@ -33,6 +34,16 @@ export function useTransactionPayPostQuote(): void { const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [ TransactionType.moneyAccountWithdraw, ]); + const isPredictWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.predictWithdraw, + ]); + + const { data: accountState } = usePredictAccountState({ + enabled: isPredictWithdraw, + }); + + const isDepositWalletWithdraw = + isPredictWithdraw && accountState?.walletType === 'deposit-wallet'; useEffect(() => { if ( @@ -43,6 +54,10 @@ export function useTransactionPayPostQuote(): void { return; } + if (isPredictWithdraw && !accountState) { + return; + } + try { const { TransactionPayController } = Engine.context; const from = transactionMeta?.txParams?.from as Hex | undefined; @@ -52,7 +67,7 @@ export function useTransactionPayPostQuote(): void { // on the user's address directly (HyperCore -> Relay for perps; vault // teller -> user for money account). const refundTo = - isPerpsWithdraw || isMoneyAccountWithdraw + isPerpsWithdraw || isMoneyAccountWithdraw || isDepositWalletWithdraw ? undefined : from ? computeProxyAddress(from) @@ -68,6 +83,10 @@ export function useTransactionPayPostQuote(): void { if (isPerpsWithdraw) { config.isHyperliquidSource = true; } + + if (isDepositWalletWithdraw) { + config.isPolymarketDepositWallet = true; + } }); isSet.current = transactionId; @@ -77,6 +96,7 @@ export function useTransactionPayPostQuote(): void { refundTo, isPerpsWithdraw, isMoneyAccountWithdraw, + isDepositWalletWithdraw, }); } catch (error) { log('Error initializing post-quote transaction', { @@ -85,9 +105,12 @@ export function useTransactionPayPostQuote(): void { }); } }, [ + accountState, canSelectWithdrawToken, + isDepositWalletWithdraw, isMoneyAccountWithdraw, isPerpsWithdraw, + isPredictWithdraw, transactionId, transactionMeta?.txParams?.from, ]); diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index d811d27d661..6e83cb4a441 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -235,6 +235,7 @@ describe('Transaction Controller Init', () => { bufferSubsequent: 0.05, slippage: 0.005, stxDisabled: false, + enableDepositWalletWithdraw: false, }); payHookClassMock.mockReturnValue({ @@ -454,6 +455,7 @@ describe('Transaction Controller Init', () => { bufferSubsequent: 0.05, slippage: 0.005, stxDisabled: true, + enableDepositWalletWithdraw: false, }); const hooks = testConstructorOption('hooks'); @@ -471,6 +473,7 @@ describe('Transaction Controller Init', () => { bufferSubsequent: 0.05, slippage: 0.005, stxDisabled: false, + enableDepositWalletWithdraw: false, }); const hooks = testConstructorOption('hooks'); diff --git a/app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.test.ts b/app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.test.ts new file mode 100644 index 00000000000..fc0d99dac25 --- /dev/null +++ b/app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.test.ts @@ -0,0 +1,205 @@ +import { + SignTypedDataVersion, + type PersonalMessageParams, + type TypedMessageParams, +} from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; + +import { + deriveDepositWalletAddress, + executeDepositWalletBatchAndWaitForCompletion, +} from '../../../../components/UI/Predict/providers/polymarket/depositWallet'; +import type { TransactionPayControllerInitMessenger } from '../../messengers/transaction-pay-controller-messenger'; +import { createPolymarketCallbacks } from './polymarket-callbacks'; + +jest.mock( + '../../../../components/UI/Predict/providers/polymarket/depositWallet', +); + +const EOA_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const DEPOSIT_WALLET_MOCK = '0x2222222222222222222222222222222222222222' as Hex; +const SOURCE_HASH_MOCK = `0x${'aa'.repeat(32)}` as Hex; + +const CALLS_MOCK = [ + { + target: '0x3333333333333333333333333333333333333333' as Hex, + data: '0x' as Hex, + value: '0', + }, +]; + +function buildInitMessenger() { + return { + call: jest.fn(), + } as unknown as jest.Mocked; +} + +describe('createPolymarketCallbacks', () => { + const deriveDepositWalletAddressMock = jest.mocked( + deriveDepositWalletAddress, + ); + const executeDepositWalletBatchAndWaitForCompletionMock = jest.mocked( + executeDepositWalletBatchAndWaitForCompletion, + ); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + deriveDepositWalletAddressMock.mockReturnValue(DEPOSIT_WALLET_MOCK); + executeDepositWalletBatchAndWaitForCompletionMock.mockResolvedValue( + SOURCE_HASH_MOCK, + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getDepositWalletAddress', () => { + it('returns the derived deposit-wallet address for the given EOA', async () => { + const callbacks = createPolymarketCallbacks(buildInitMessenger()); + + const result = await callbacks.getDepositWalletAddress({ eoa: EOA_MOCK }); + + expect(result).toBe(DEPOSIT_WALLET_MOCK); + expect(deriveDepositWalletAddressMock).toHaveBeenCalledWith(EOA_MOCK); + }); + }); + + describe('submitDepositWalletBatch', () => { + it('returns the relayer source hash on success', async () => { + const initMessenger = buildInitMessenger(); + const callbacks = createPolymarketCallbacks(initMessenger); + + const result = await callbacks.submitDepositWalletBatch({ + eoa: EOA_MOCK, + depositWallet: DEPOSIT_WALLET_MOCK, + calls: CALLS_MOCK, + }); + + expect(result).toStrictEqual({ sourceHash: SOURCE_HASH_MOCK }); + expect( + executeDepositWalletBatchAndWaitForCompletionMock, + ).toHaveBeenCalledTimes(1); + expect( + executeDepositWalletBatchAndWaitForCompletionMock.mock.calls[0][0], + ).toMatchObject({ + walletAddress: DEPOSIT_WALLET_MOCK, + calls: CALLS_MOCK, + signer: expect.objectContaining({ address: EOA_MOCK }), + }); + }); + + it('signs typed and personal messages via the init messenger', async () => { + const initMessenger = buildInitMessenger(); + initMessenger.call.mockImplementation(((action: string) => { + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xtyped'); + } + if (action === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xpersonal'); + } + return undefined; + }) as never); + const callbacks = createPolymarketCallbacks(initMessenger); + + await callbacks.submitDepositWalletBatch({ + eoa: EOA_MOCK, + depositWallet: DEPOSIT_WALLET_MOCK, + calls: CALLS_MOCK, + }); + + const signer = + executeDepositWalletBatchAndWaitForCompletionMock.mock.calls[0][0] + .signer; + + const typedParams = {} as TypedMessageParams; + const typedResult = await signer.signTypedMessage( + typedParams, + SignTypedDataVersion.V4, + ); + const personalParams = {} as PersonalMessageParams; + const personalResult = await signer.signPersonalMessage(personalParams); + + expect(typedResult).toBe('0xtyped'); + expect(personalResult).toBe('0xpersonal'); + expect(initMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + typedParams, + SignTypedDataVersion.V4, + ); + expect(initMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + personalParams, + ); + }); + + it('retries when the relayer reports "wallet busy" and eventually succeeds', async () => { + executeDepositWalletBatchAndWaitForCompletionMock + .mockRejectedValueOnce(new Error('wallet busy: try again')) + .mockRejectedValueOnce(new Error('Wallet Busy: still pending')) + .mockResolvedValueOnce(SOURCE_HASH_MOCK); + + const callbacks = createPolymarketCallbacks(buildInitMessenger()); + + const promise = callbacks.submitDepositWalletBatch({ + eoa: EOA_MOCK, + depositWallet: DEPOSIT_WALLET_MOCK, + calls: CALLS_MOCK, + }); + + await jest.runAllTimersAsync(); + + await expect(promise).resolves.toStrictEqual({ + sourceHash: SOURCE_HASH_MOCK, + }); + expect( + executeDepositWalletBatchAndWaitForCompletionMock, + ).toHaveBeenCalledTimes(3); + }); + + it('rethrows non wallet-busy errors immediately without retrying', async () => { + executeDepositWalletBatchAndWaitForCompletionMock.mockRejectedValue( + new Error('relayer rejected'), + ); + + const callbacks = createPolymarketCallbacks(buildInitMessenger()); + + await expect( + callbacks.submitDepositWalletBatch({ + eoa: EOA_MOCK, + depositWallet: DEPOSIT_WALLET_MOCK, + calls: CALLS_MOCK, + }), + ).rejects.toThrow('relayer rejected'); + + expect( + executeDepositWalletBatchAndWaitForCompletionMock, + ).toHaveBeenCalledTimes(1); + }); + + it('gives up after exhausting wallet-busy retries', async () => { + executeDepositWalletBatchAndWaitForCompletionMock.mockRejectedValue( + new Error('wallet busy: persistent'), + ); + + const callbacks = createPolymarketCallbacks(buildInitMessenger()); + + const promise = callbacks.submitDepositWalletBatch({ + eoa: EOA_MOCK, + depositWallet: DEPOSIT_WALLET_MOCK, + calls: CALLS_MOCK, + }); + + const captured = promise.catch((error) => error); + await jest.runAllTimersAsync(); + const error = await captured; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('wallet busy: persistent'); + expect( + executeDepositWalletBatchAndWaitForCompletionMock, + ).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.ts b/app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.ts new file mode 100644 index 00000000000..b4aa6cb1798 --- /dev/null +++ b/app/core/Engine/controllers/transaction-pay-controller/polymarket-callbacks.ts @@ -0,0 +1,101 @@ +import { + SignTypedDataVersion, + type PersonalMessageParams, + type TypedMessageParams, +} from '@metamask/keyring-controller'; +import type { PolymarketCallbacks } from '@metamask/transaction-pay-controller'; +import type { Hex } from '@metamask/utils'; + +import { + deriveDepositWalletAddress, + executeDepositWalletBatchAndWaitForCompletion, +} from '../../../../components/UI/Predict/providers/polymarket/depositWallet'; +import type { Signer } from '../../../../components/UI/Predict/providers/types'; +import type { TransactionPayControllerInitMessenger } from '../../messengers/transaction-pay-controller-messenger'; + +const WALLET_BUSY_RETRY_ATTEMPTS = 5; +const WALLET_BUSY_RETRY_DELAY_MS = 3000; +const WALLET_BUSY_ERROR_MARKER = 'wallet busy'; + +export function createPolymarketCallbacks( + initMessenger: TransactionPayControllerInitMessenger, +): PolymarketCallbacks { + return { + getDepositWalletAddress: ({ eoa }) => getDepositWalletAddress(eoa), + + submitDepositWalletBatch: ({ eoa, depositWallet, calls }) => + submitDepositWalletBatch(initMessenger, { eoa, depositWallet, calls }), + }; +} + +async function getDepositWalletAddress(eoa: Hex): Promise { + return deriveDepositWalletAddress(eoa) as Hex; +} + +async function submitDepositWalletBatch( + initMessenger: TransactionPayControllerInitMessenger, + { + eoa, + depositWallet, + calls, + }: { + eoa: Hex; + depositWallet: Hex; + calls: { target: Hex; data: Hex; value: string }[]; + }, +): Promise<{ sourceHash: Hex }> { + return withWalletBusyRetry(async () => { + const sourceHash = await executeDepositWalletBatchAndWaitForCompletion({ + signer: createSigner(initMessenger, eoa), + walletAddress: depositWallet, + calls, + }); + return { sourceHash }; + }); +} + +function createSigner( + initMessenger: TransactionPayControllerInitMessenger, + address: Hex, +): Signer { + return { + address, + signTypedMessage: ( + params: TypedMessageParams, + version: SignTypedDataVersion, + ) => + initMessenger.call('KeyringController:signTypedMessage', params, version), + signPersonalMessage: (params: PersonalMessageParams) => + initMessenger.call('KeyringController:signPersonalMessage', params), + }; +} + +async function withWalletBusyRetry(action: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { + try { + return await action(); + } catch (error) { + lastError = error; + if ( + !isWalletBusyError(error) || + attempt === WALLET_BUSY_RETRY_ATTEMPTS - 1 + ) { + throw error; + } + await sleep(WALLET_BUSY_RETRY_DELAY_MS); + } + } + throw lastError; +} + +function isWalletBusyError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return error.message.toLowerCase().includes(WALLET_BUSY_ERROR_MARKER); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.test.ts b/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.test.ts index 79bcbeb8628..b0c5c2b542b 100644 --- a/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.test.ts @@ -9,8 +9,10 @@ import { } from '@metamask/transaction-pay-controller'; import { TransactionPayControllerInit } from './transaction-pay-controller-init'; import { TransactionPayControllerInitMessenger } from '../../messengers/transaction-pay-controller-messenger'; +import { createPolymarketCallbacks } from './polymarket-callbacks'; jest.mock('@metamask/transaction-pay-controller'); +jest.mock('./polymarket-callbacks'); function buildInitRequestMock( initRequestProperties: Record = {}, @@ -99,4 +101,20 @@ describe('Transaction Pay Controller Init', () => { expect(getStrategy).toBeUndefined(); expect(getStrategies).toBeUndefined(); }); + + it('wires Polymarket callbacks into the controller', () => { + const polymarketCallbacksMock = { __polymarketCallbacks: true }; + jest + .mocked(createPolymarketCallbacks) + .mockReturnValue( + polymarketCallbacksMock as unknown as ReturnType< + typeof createPolymarketCallbacks + >, + ); + + const polymarket = testConstructorOption('polymarket'); + + expect(createPolymarketCallbacks).toHaveBeenCalledTimes(1); + expect(polymarket).toBe(polymarketCallbacksMock); + }); }); diff --git a/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.ts b/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.ts index b42d20c38a4..9fededcc906 100644 --- a/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.ts +++ b/app/core/Engine/controllers/transaction-pay-controller/transaction-pay-controller-init.ts @@ -6,6 +6,7 @@ import { } from '@metamask/transaction-pay-controller'; import { TransactionPayControllerInitMessenger } from '../../messengers/transaction-pay-controller-messenger'; import { getDelegationTransaction } from '../../../../util/transactions/delegation'; +import { createPolymarketCallbacks } from './polymarket-callbacks'; export const TransactionPayControllerInit: MessengerClientInitFunction< TransactionPayController, @@ -19,6 +20,7 @@ export const TransactionPayControllerInit: MessengerClientInitFunction< getDelegationTransaction: ({ transaction }) => getDelegationTransaction(initMessenger, transaction), messenger: controllerMessenger, + polymarket: createPolymarketCallbacks(initMessenger), state: persistedState.TransactionPayController, }); diff --git a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts index 9d029afd548..ceaceb669fd 100644 --- a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts @@ -47,6 +47,8 @@ import { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStateAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerPolymarketGetDepositWalletAddressAction, + TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, } from '@metamask/transaction-pay-controller'; import { RootMessenger } from '../../types'; import { AnalyticsControllerActions } from '@metamask/analytics-controller'; @@ -118,6 +120,8 @@ type InitMessengerActions = | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction + | TransactionPayControllerPolymarketGetDepositWalletAddressAction + | TransactionPayControllerPolymarketSubmitDepositWalletBatchAction | AnalyticsControllerActions | PredictControllerBeforePublishAction | PredictControllerPublishAction; @@ -178,6 +182,8 @@ export function getTransactionControllerInitMessenger( 'TransactionPayController:getDelegationTransaction', 'TransactionPayController:getState', 'TransactionPayController:getStrategy', + 'TransactionPayController:polymarketGetDepositWalletAddress', + 'TransactionPayController:polymarketSubmitDepositWalletBatch', 'AnalyticsController:trackEvent', 'PredictController:beforePublish', 'PredictController:publish', diff --git a/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts b/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts index 62f26d46b5c..52ad9f30c6d 100644 --- a/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts @@ -6,7 +6,11 @@ import { MessengerEvents, } from '@metamask/messenger'; import { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; -import { KeyringControllerSignEip7702AuthorizationAction } from '@metamask/keyring-controller'; +import { + KeyringControllerSignEip7702AuthorizationAction, + KeyringControllerSignPersonalMessageAction, + KeyringControllerSignTypedMessageAction, +} from '@metamask/keyring-controller'; export function getTransactionPayControllerMessenger( rootMessenger: RootMessenger, @@ -59,7 +63,9 @@ export function getTransactionPayControllerMessenger( type InitMessengerActions = | DelegationControllerSignDelegationAction - | KeyringControllerSignEip7702AuthorizationAction; + | KeyringControllerSignEip7702AuthorizationAction + | KeyringControllerSignPersonalMessageAction + | KeyringControllerSignTypedMessageAction; type InitMessengerEvents = never; export type TransactionPayControllerInitMessenger = ReturnType< @@ -83,6 +89,8 @@ export function getTransactionPayControllerInitMessenger( actions: [ 'DelegationController:signDelegation', 'KeyringController:signEip7702Authorization', + 'KeyringController:signPersonalMessage', + 'KeyringController:signTypedMessage', ], events: [], messenger, diff --git a/app/selectors/featureFlagController/confirmations/index.test.ts b/app/selectors/featureFlagController/confirmations/index.test.ts index dd7c7906f8d..f2f79b55792 100644 --- a/app/selectors/featureFlagController/confirmations/index.test.ts +++ b/app/selectors/featureFlagController/confirmations/index.test.ts @@ -16,6 +16,7 @@ import { PAY_FIAT_ENABLED_TRANSACTION_TYPES, PAY_FIAT_MAX_DELAY_MINUTES_FOR_PAYMENT_METHODS, selectMetaMaskPayHardwareFlags, + PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT, PAY_HARDWARE_ENABLED_DEFAULT, PreferredToken, getPreferredTokensForTransactionType, @@ -563,3 +564,23 @@ describe('selectMetaMaskPayHardwareFlags', () => { expect(selectMetaMaskPayHardwareFlags(state)).toEqual({ enabled: true }); }); }); + +describe('selectMetaMaskPayFlags extended flags', () => { + it('returns default enableDepositWalletWithdraw when flag is absent', () => { + expect( + selectMetaMaskPayFlags(mockedEmptyFlagsState).enableDepositWalletWithdraw, + ).toEqual(PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT); + }); + + it('returns enableDepositWalletWithdraw from flag value', () => { + const state = cloneDeep(mockedEmptyFlagsState); + state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = + { + confirmations_pay_extended: { enableDepositWalletWithdraw: true }, + }; + + expect(selectMetaMaskPayFlags(state).enableDepositWalletWithdraw).toEqual( + true, + ); + }); +}); diff --git a/app/selectors/featureFlagController/confirmations/index.ts b/app/selectors/featureFlagController/confirmations/index.ts index ba5e16f951d..f1915b20bdf 100644 --- a/app/selectors/featureFlagController/confirmations/index.ts +++ b/app/selectors/featureFlagController/confirmations/index.ts @@ -11,6 +11,7 @@ export const BUFFER_SUBSEQUENT_DEFAULT = 0.05; export const PAY_FIAT_ENABLED_TRANSACTION_TYPES = []; export const PAY_FIAT_MAX_DELAY_MINUTES_FOR_PAYMENT_METHODS = 10; export const PAY_HARDWARE_ENABLED_DEFAULT = false; +export const PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT = false; export const SLIPPAGE_DEFAULT = 0.005; export const STX_DISABLED_DEFAULT = false; @@ -49,6 +50,10 @@ export interface MetaMaskPayFlags { stxDisabled: boolean; } +export interface MetaMaskPayExtendedFlags { + enableDepositWalletWithdraw: boolean; +} + export interface MetaMaskPayTokensFlags { preferredTokens: PreferredTokensConfig; blockedTokens: BlockedTokensConfig; @@ -88,11 +93,16 @@ export interface MetaMaskPayHardwareFlags { export const selectMetaMaskPayFlags = createSelector( selectRemoteFeatureFlags, - (featureFlags): MetaMaskPayFlags => { + (featureFlags): MetaMaskPayFlags & MetaMaskPayExtendedFlags => { const metaMaskPayFlags = featureFlags?.confirmations_pay as | Record | undefined; + const metaMaskPayExtendedFlags = + featureFlags?.confirmations_pay_extended as + | Record + | undefined; + const attemptsMax = (metaMaskPayFlags?.attemptsMax as number) ?? ATTEMPTS_MAX_DEFAULT; @@ -111,6 +121,10 @@ export const selectMetaMaskPayFlags = createSelector( const stxDisabled = (metaMaskPayFlags?.stxDisabled as boolean) ?? STX_DISABLED_DEFAULT; + const enableDepositWalletWithdraw = + (metaMaskPayExtendedFlags?.enableDepositWalletWithdraw as boolean) ?? + PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT; + return { attemptsMax, bufferInitial, @@ -118,6 +132,7 @@ export const selectMetaMaskPayFlags = createSelector( bufferSubsequent, slippage, stxDisabled, + enableDepositWalletWithdraw, }; }, ); diff --git a/locales/languages/en.json b/locales/languages/en.json index 8e99265ab3c..99a4494c2ba 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8911,6 +8911,7 @@ "musd_claim": "Claim mUSD", "perps_deposit": "Add funds", "perps_withdraw": "Withdrawal", + "predict_withdraw": "Withdraw {{sourceSymbol}} from {{sourceChain}}", "predict_deposit": "Add funds", "swap": "Swap tokens", "swap_approval": "Approve tokens", diff --git a/package.json b/package.json index b00ebf64351..2d1c08419dc 100644 --- a/package.json +++ b/package.json @@ -349,7 +349,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^65.4.0", - "@metamask/transaction-pay-controller": "^22.4.0", + "@metamask/transaction-pay-controller": "^22.5.0", "@metamask/tron-wallet-snap": "^1.25.3", "@metamask/utils": "^11.11.0", "@myx-trade/sdk": "^0.1.265", diff --git a/yarn.lock b/yarn.lock index ffa65483b04..11c9ccab726 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10427,9 +10427,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^22.4.0": - version: 22.4.0 - resolution: "@metamask/transaction-pay-controller@npm:22.4.0" +"@metamask/transaction-pay-controller@npm:^22.5.0": + version: 22.5.0 + resolution: "@metamask/transaction-pay-controller@npm:22.5.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -10452,7 +10452,7 @@ __metadata: bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/3de949b9525531bedcbc7fb24ab3470f38f014f3239b1e32d47654a2d25bffa67aab6ad3da39784a6321fdf36757a7f9c0e85946dfb33bb53d00a2a8ba728d34 + checksum: 10/611103e0f4a8c2783f8196302830d9befee8973f64e0fdb050f658de3519c6dc01c1bf104788b2d726e8f22e22ed529ac19b7b3e9d53aea945e6676d71bef7e2 languageName: node linkType: hard @@ -35442,7 +35442,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^65.4.0" - "@metamask/transaction-pay-controller": "npm:^22.4.0" + "@metamask/transaction-pay-controller": "npm:^22.5.0" "@metamask/tron-wallet-snap": "npm:^1.25.3" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265"