diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9db7039365..62e889b089d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=12288 - name: Check bundle size - run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 54 + run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 53 - name: Upload iOS bundle uses: actions/upload-artifact@v4 diff --git a/.js.env.example b/.js.env.example index 7524dbbbeae..83b4d97b81b 100644 --- a/.js.env.example +++ b/.js.env.example @@ -22,15 +22,7 @@ export SENTRY_DISABLE_AUTO_UPLOAD="true" # ENV vars for e2e tests # Only enable it for e2e tests export IS_TEST="false" -# Performance E2E (Appwright): set on the bundle step in CI so seedless/OAuth Metro mocks are explicitly opted in. -# Optional locally when pairing with METAMASK_ENVIRONMENT=e2e. -# export PERFORMANCE_TEST_JOB="true" -# Force-disable seedless + OAuth Metro redirects while keeping other E2E behavior (Sentry mocks, etc.): -# export E2E_USE_SEEDLESS_OAUTH_METRO_MOCK="false" -# Finer control: disable OAuth handler Metro mock for non-seedless -# onboarding perf builds; keep seedless perf on default (unset) so seedless-*.spec.js use mocks. -# export E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK="false" -# export E2E_USE_SEEDLESS_CONTROLLER_METRO_MOCK="false" +# Seedless + OAuthLoginHandlers Metro mocks: on when IS_TEST/e2e OR E2E_MOCK_OAUTH=true at bundle time. # defined as secrets to run on Bitrise CI # but have to be defined here for local tests export MM_TEST_ACCOUNT_SRP="" diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 0d1092f4961..f2f0f3c897e 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -117,6 +117,8 @@ import { selectMarketInsightsEnabled, } from '../../UI/MarketInsights'; import { selectMarketInsightsPerpsEnabled } from '../../../selectors/featureFlagController/marketInsights'; +import { TopTradersView } from '../../Views/SocialLeaderboard'; +import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView'; import PerpsOrderTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView'; import PerpsFundingTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView'; @@ -942,6 +944,9 @@ const MainNavigator = () => { const isMarketInsightsPerpsEnabled = useSelector( selectMarketInsightsPerpsEnabled, ); + const isSocialLeaderboardEnabled = useSelector( + selectSocialLeaderboardEnabled, + ); return ( { options={{ headerShown: false, ...slideFromRightAnimation }} /> )} + {isSocialLeaderboardEnabled && ( + + )} <> ({ + getVersion: jest.fn(() => '7.72.0'), +})); + jest.mock('@react-navigation/stack', () => ({ createStackNavigator: jest.fn().mockReturnValue({ Navigator: 'Navigator', @@ -145,4 +149,56 @@ describe('MainNavigator', () => { 'FeatureFlagOverride', ); }); + + it('includes TopTradersView screen when Social Leaderboard remote flag is enabled', () => { + const stateWithSocialLeaderboard = { + ...initialRootState, + engine: { + ...initialRootState.engine, + backgroundState: { + ...initialRootState.engine.backgroundState, + RemoteFeatureFlagController: { + ...initialRootState.engine.backgroundState + .RemoteFeatureFlagController, + remoteFeatureFlags: { + ...initialRootState.engine.backgroundState + .RemoteFeatureFlagController.remoteFeatureFlags, + aiSocialLeaderboardEnabled: { + enabled: true, + minimumVersion: '0.0.1', + }, + }, + }, + }, + }, + }; + + const container = renderWithProvider(, { + state: stateWithSocialLeaderboard, + }); + + interface ScreenChild { + name: string; + component: { name: string }; + } + const screenProps: ScreenChild[] = container.root.children + .filter( + (child): child is ReactTestInstance => + typeof child === 'object' && + 'type' in child && + 'props' in child && + child.type?.toString() === 'Screen', + ) + .map((child) => ({ + name: child.props.name, + component: child.props.component, + })); + + const topTradersScreen = screenProps?.find( + (screen) => screen?.name === Routes.SOCIAL_LEADERBOARD.VIEW, + ); + + expect(topTradersScreen).toBeDefined(); + expect(topTradersScreen?.component.name).toBe('TopTradersView'); + }); }); diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 2fd220a5431..127b0ae5f19 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -408,7 +408,9 @@ const BridgeView = () => { true} + onStartShouldSetResponder={() => + !(contentMode === 'zero' && isSwapsTrendingTokensEnabled) + } onResponderRelease={() => { inputRef.current?.blur(); keypadRef.current?.close(); diff --git a/app/components/UI/Predict/README.md b/app/components/UI/Predict/README.md index 22222353c18..5c910234880 100644 --- a/app/components/UI/Predict/README.md +++ b/app/components/UI/Predict/README.md @@ -60,6 +60,7 @@ The Predict feature enables users to participate in prediction markets within Me │ ├── format.ts # Price, percentage, and volume formatting │ └── orders.ts # Order ID generation utilities ├── /views # Main screen components +│ ├── /PredictBuyWithAnyToken # Buy/order flow (single-route architecture) │ ├── /PredictCashOut # Cash out/redeem positions screen │ ├── /PredictMarketDetails # Individual market details screen │ ├── /PredictMarketList # Market listing screen @@ -137,6 +138,114 @@ Component input → Hook state → Validation → Controller action | Price history | `usePredictPriceHistory` | Price history fetching with pagination, search, infinite scroll, and retry logic | | Order notifications | `usePredictOrders` | Automatic toast notifications, status tracking | +## PredictBuyWithAnyToken + +The buy/order flow lives in `views/PredictBuyWithAnyToken/`. This is the primary screen where users place prediction market orders. Everything — direct orders, deposit-and-order flows, and pay-with-any-token flows — happens on a **single route** without navigation redirects. + +### Single-Route Architecture + +All order states (preview, token selection, deposit, order placement) are managed by `PredictController` and rendered inline within `PredictBuyWithAnyToken`. The confirmation transaction (`PredictPayWithAnyTokenInfo`) is mounted as a headless component that syncs deposit amounts and payment tokens via effects, rather than living on a separate navigation screen. When an external payment token is selected, `initPayWithAnyToken()` fires on the initial `transitionEnd` event to prepare the deposit-and-order batch in the background. + +Flow logic (deposit → order chaining, error handling, state transitions) lives in `PredictController` — hooks react to `activeOrder.state` changes via effects rather than driving transitions themselves. + +### Components + +| Component | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `PredictBuyActionButton` | Main CTA button with loading/disabled states tied to order lifecycle | +| `PredictBuyAmountSection` | Keypad and amount input for entering bet size; disables input interaction while order is placing | +| `PredictBuyBottomContent` | Bottom area layout (fee summary, action button) | +| `PredictBuyError` | Generic error display for all buy-flow errors (minimum bet, insufficient balance, order failures, insufficient pay token balance) | +| `PredictBuyPreviewHeader` | Header showing market/outcome info with `outcomeToken` prop for direct token resolution (falls back to route param token, not first token) | +| `PredictFeeSummary` | Breakdown of MetaMask fee, provider fee, deposit fee, and total | +| `PredictPayWithAnyTokenInfo` | Headless component that syncs deposit amount and payment token to the confirmation transaction; renders only when `transactionMeta` exists | +| `PredictPayWithRow` | Payment token selector row — always visible (Predict balance or external tokens); falls back to Predict balance when payToken is null | + +### Hooks + +| Hook | Description | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `usePredictBuyActions` | Orchestrates buy flow lifecycle: analytics tracking on mount, `transitionEnd` initialization for `initPayWithAnyToken()`, back-navigation cleanup, confirm/deposit/order logic, and SUCCESS → dismiss. Returns `handleConfirm` and `placeOrder` | +| `usePredictBuyConditions` | Derives boolean flags (`canPlaceBet`, `isBelowMinimum`, `isInsufficientBalance`, `isInsufficientPayTokenBalance`, `isRateLimited`, `maxBetAmount`, `isPayFeesLoading`, `isBalancePulsing`, etc.) from order and preview state. Includes stale-quote detection to bridge `TransactionPayController` timing gaps | +| `usePredictBuyError` | Derives error messages from active order state, preview errors, minimum bet violations, insufficient balance, and insufficient pay token balance. Detects order-not-filled errors for the retry flow | +| `usePredictBuyInfo` | Computes display values (`toWin`, `metamaskFee`, `providerFee`, `depositFee`, `depositAmount`, `total`, `rewardsFeeAmount`) from preview, `TransactionPayController` totals, and Predict balance | +| `usePredictBuyInputState` | Manages keypad input value, user-change tracking, input focus state, and `isConfirming` flag. Clears active order errors on user input change | +| `usePredictBuyAvailableBalance` | Resolves the available balance as a raw number — Predict balance when using balance, or Predict balance + external token balance when using an external token | + +## Active Order Lifecycle + +The `activeBuyOrder` in `PredictControllerState` tracks the full lifecycle of a single buy order from preview to completion. Only one order can be active at a time. All state transitions are owned by `PredictController` methods — hooks react to state changes via effects rather than driving transitions themselves. + +When the active order enters `PAY_WITH_ANY_TOKEN` and `placeOrder()` is called, the controller stores the preview and analytics in an in-memory `pendingOrderPreviews` map keyed by `transactionId`. After the deposit transaction confirms, `handleTransactionSideEffects()` looks up the stored preview and automatically calls `placeOrder()` to complete the order. + +### State Shape + +```typescript +activeBuyOrder: { + transactionId?: string; // Transaction ID linking deposit to order (for deposit-and-order flow) + state: ActiveOrderState; // Current lifecycle state + error?: string; // Error message from failed operations +} | null; +``` + +### ActiveOrderState + +```typescript +enum ActiveOrderState { + PREVIEW = 'preview', // User is editing amount on the keypad + PAY_WITH_ANY_TOKEN = 'pay_with_any_token', // External token selected, deposit-and-order tx prepared in background + DEPOSITING = 'depositing', // Deposit transaction in progress (set by placeOrder when state is PAY_WITH_ANY_TOKEN) + PLACING_ORDER = 'placing_order', // Order submission in flight + SUCCESS = 'success', // Order completed, about to dismiss +} +``` + +### State Machine + +```mermaid +stateDiagram-v2 + [*] --> PREVIEW: initPayWithAnyToken() + + PREVIEW --> PAY_WITH_ANY_TOKEN: selectPaymentToken(external token) + PAY_WITH_ANY_TOKEN --> PREVIEW: selectPaymentToken(balance token) + + PREVIEW --> PLACING_ORDER: placeOrder() [balance selected] + PAY_WITH_ANY_TOKEN --> DEPOSITING: placeOrder() [external token selected] + + DEPOSITING --> PLACING_ORDER: handleTransactionSideEffects(depositAndOrder confirmed) → placeOrder() + DEPOSITING --> PREVIEW: handleTransactionSideEffects(depositAndOrder failed) [sets error, retries initPayWithAnyToken] + + PLACING_ORDER --> SUCCESS: placeOrder() succeeds + PLACING_ORDER --> PREVIEW: placeOrder() fails [sets error, clears payment token, retries initPayWithAnyToken] + + SUCCESS --> [*]: onPlaceOrderEnd() + navigation pop +``` + +Notes: + +- Back navigation or approval rejection triggers `onPlaceOrderEnd()`, which clears the active order, payment token, and deposit preview. +- Deposit failure resets to `PREVIEW`, stores the error on `activeBuyOrder.error`, clears `transactionId`, and automatically retries `initPayWithAnyToken()`. +- Order failure resets to `PREVIEW`, stores the error, clears `selectedPaymentToken`, and if a `transactionId` was present, clears it and retries `initPayWithAnyToken()`. +- The `transitionEnd` listener in `usePredictBuyActions` triggers `initPayWithAnyToken()` once on initial mount to prepare the deposit-and-order batch when an external token is selected. +- Transaction status events (`TransactionController:transactionStatusUpdated`) for `predictDepositAndOrder` are handled by `handleTransactionSideEffects()` in the controller, which chains deposit confirmation into `placeOrder()` automatically using the preview stored in `pendingOrderPreviews`. +- When `placeOrder()` is called while the active order state is `PAY_WITH_ANY_TOKEN`, it transitions to `DEPOSITING`, stores the preview in `pendingOrderPreviews[transactionId]`, and returns early. The actual order placement happens when the deposit transaction confirms. +- On successful order placement, foreground flows invalidate order-related queries in Predict hooks, while background flows publish `PredictController:transactionStatusChanged` events that trigger app-level query invalidation and toast notifications. +- State transitions are gated behind the `predictWithAnyToken` feature flag — when disabled, `placeOrder()` behaves as a direct order without active order state management. + +### Controller Methods (State Transitions) + +| Method | Transition | Notes | +| --------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initPayWithAnyToken()` | Sets `transactionId` on active order | Prepares deposit-and-order batch via provider; initializes to `PREVIEW` if no active order exists; guards against duplicates | +| `selectPaymentToken()` | `PREVIEW ↔ PAY_WITH_ANY_TOKEN` | Toggles between balance and external token; sets/clears `selectedPaymentToken` and clears error | +| `placeOrder()` | `PAY_WITH_ANY_TOKEN -> DEPOSITING` | When external token selected: stores preview in `pendingOrderPreviews`, transitions to `DEPOSITING`, returns early | +| `placeOrder()` | `PREVIEW -> PLACING_ORDER` | When balance selected: submits order directly to provider | +| `placeOrder()` | `PLACING_ORDER -> SUCCESS` | On successful order completion; optimistically updates balance; foreground hooks invalidate queries, and background flows publish an order confirmed event | +| `placeOrder()` | `PLACING_ORDER -> PREVIEW` | On order failure; stores error, clears payment token; if `transactionId` present, clears it and retries `initPayWithAnyToken()` | +| `onPlaceOrderEnd()` | `-> null` | Clears active order, payment token, and deposit preview | +| `clearOrderError()` | (no state change) | Removes error from active order | +| `setSelectedPaymentToken()` | (no state change) | Directly sets or clears the selected payment token in state | + ## Core Types and Utilities ### Key Types (`/types/index.ts`) diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx index 55f2b0bc4ea..76d2285c8b5 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx @@ -29,9 +29,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ activeOrder: null, - updateActiveOrder: jest.fn(), - initializeActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx index 30bbcea0a3b..97e86a5a927 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx @@ -29,10 +29,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx index a2105c1d29d..2846bc1f31e 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx @@ -35,10 +35,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx index 58bd12ed60f..06dda832e79 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx @@ -30,10 +30,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/constants/errors.ts b/app/components/UI/Predict/constants/errors.ts index acfde59c463..e6f8b1a16f5 100644 --- a/app/components/UI/Predict/constants/errors.ts +++ b/app/components/UI/Predict/constants/errors.ts @@ -30,6 +30,7 @@ export const PREDICT_ERROR_CODES = { WITHDRAW_FAILED: 'PREDICT_WITHDRAW_FAILED', BUY_ORDER_NOT_FULLY_FILLED: 'PREDICT_BUY_ORDER_NOT_FULLY_FILLED', SELL_ORDER_NOT_FULLY_FILLED: 'PREDICT_SELL_ORDER_NOT_FULLY_FILLED', + PREVIEW_NOT_AVAILABLE: 'PREDICT_PREVIEW_NOT_AVAILABLE', } as const; export const getPredictErrorMessages = () => @@ -67,4 +68,7 @@ export const getPredictErrorMessages = () => [PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED]: strings( 'predict.error_messages.sell_order_not_fully_filled', ), + [PREDICT_ERROR_CODES.PREVIEW_NOT_AVAILABLE]: strings( + 'predict.error_messages.preview_not_available', + ), }) as const; diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 07a81ee7c8b..5f87b48d957 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -1,22 +1,23 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - MOCK_ANY_NAMESPACE, Messenger, type MessengerActions, type MessengerEvents, + MOCK_ANY_NAMESPACE, type MockAnyNamespace, } from '@metamask/messenger'; -import type { NetworkState } from '@metamask/network-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkState } from '@metamask/network-controller'; import { - TransactionStatus, type TransactionMeta, + TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import { analytics } from '../../../../util/analytics/analytics'; import { addTransaction, addTransactionBatch, @@ -25,6 +26,7 @@ import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { ActiveOrderState, type OrderPreview, + type PlaceOrderParams, PredictBalance, PredictClaimStatus, PredictPosition, @@ -38,8 +40,9 @@ import { PredictControllerMessenger, type PredictControllerState, } from './PredictController'; -import { analytics } from '../../../../util/analytics/analytics'; +import type { PredictFeatureFlags } from '../types/flags'; +import { PREDICT_ERROR_CODES } from '../constants/errors'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; // Mock the PolymarketProvider and its dependencies jest.mock('../providers/polymarket/PolymarketProvider'); @@ -69,10 +72,35 @@ const DEFAULT_REMOTE_FEATURE_FLAG_STATE = { minimumVersion: '0.0.0', highlights: [], }, + predictWithAnyToken: { + enabled: false, + minimumVersion: '0.0.0', + }, }, cacheTimestamp: Date.now(), }; +const REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN = { + ...DEFAULT_REMOTE_FEATURE_FLAG_STATE, + remoteFeatureFlags: { + ...DEFAULT_REMOTE_FEATURE_FLAG_STATE.remoteFeatureFlags, + predictWithAnyToken: { + enabled: true, + minimumVersion: '0.0.0', + }, + }, +}; + +const REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN_OVERRIDE = { + ...DEFAULT_REMOTE_FEATURE_FLAG_STATE, + localOverrides: { + predictWithAnyToken: { + enabled: true, + minimumVersion: '0.0.0', + }, + }, +}; + const DEFAULT_NETWORK_CLIENT = { blockTracker: { checkForLatestBlock: jest.fn().mockResolvedValue(undefined), @@ -109,6 +137,16 @@ jest.mock('../../../../util/analytics/analytics', () => ({ }, })); +const mockInvalidateQueries = jest.fn(); +jest.mock('../../../../core/ReactQueryService', () => ({ + __esModule: true, + default: { + queryClient: { + invalidateQueries: (...args: unknown[]) => mockInvalidateQueries(...args), + }, + }, +})); + type AllPredictControllerMessengerActions = MessengerActions; @@ -132,6 +170,17 @@ function getRootMessenger(): RootMessenger { }); } +const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; + +function setActiveOrderForTest( + controller: PredictController, + order: PredictControllerState['activeBuyOrder'], +) { + controller.updateStateForTesting((state) => { + state.activeBuyOrder = order; + }); +} + describe('PredictController', () => { let mockPolymarketProvider: jest.Mocked; @@ -219,6 +268,26 @@ describe('PredictController', () => { prepareWithdrawConfirmation: jest.fn(), } as unknown as jest.Mocked; + // Default safe mocks for async fire-and-forget methods + // (prevents unhandled rejections when payWithAnyTokenConfirmation is + // triggered by onBuyPaymentTokenChange but the async chain completes + // after mock cleanup) + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [ + { + params: { + to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, + data: '0xa9059cbb' as `0x${string}`, + }, + type: TransactionType.predictDeposit, + }, + ], + chainId: '0x89', + }); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'default-batch', + }); + // Mock the PolymarketProvider constructor ( PolymarketProvider as unknown as jest.MockedClass< @@ -447,6 +516,31 @@ describe('PredictController', () => { }); }); + describe('feature flag resolution', () => { + it('uses local overrides for predictWithAnyToken', () => { + withController( + ({ controller }) => { + expect( + ( + controller as unknown as { + resolveFeatureFlags: () => PredictFeatureFlags; + } + ).resolveFeatureFlags().predictWithAnyTokenEnabled, + ).toBe(true); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue( + REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN_OVERRIDE, + ), + }, + }, + ); + }); + }); + describe('markets and positions', () => { it('get markets successfully', async () => { const mockMarkets = [ @@ -974,142 +1068,460 @@ describe('PredictController', () => { }); }); - it('handle place order errors', async () => { - await withController(async ({ controller }) => { - // Mock the provider to throw an error - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject(new Error('Order placement failed')), - ); + it('does not invalidate queries directly on successful buy order when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController( + async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + mockInvalidateQueries.mockClear(); - const preview = createMockOrderPreview({ side: Side.SELL }); + const preview = createMockOrderPreview({ side: Side.BUY }); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('Order placement failed'); + await controller.placeOrder({ preview }); - expect(controller.state.lastError).toBe('Order placement failed'); - }); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('throws provider error when placeOrder returns success false', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockResolvedValue({ - success: false, - error: 'Order rejected by provider', - } as any); + it('does not invalidate queries on successful sell order even when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '200', + receivedAmount: '100', + }, + }; + await withController( + async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + mockInvalidateQueries.mockClear(); - const preview = createMockOrderPreview({ side: Side.BUY }); + const preview = createMockOrderPreview({ side: Side.SELL }); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('Order rejected by provider'); - }); + await controller.placeOrder({ preview }); + + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('updates state with lastError when place order fails', async () => { + it('does not invalidate queries on successful buy order when predictWithAnyToken is disabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject(new Error('Network error')), - ); + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + mockInvalidateQueries.mockClear(); const preview = createMockOrderPreview({ side: Side.BUY }); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('Network error'); + await controller.placeOrder({ preview }); - expect(controller.state.lastError).toBe('Network error'); - expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); }); }); - it('logs error details when place order fails', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject(new Error('Provider error')), - ); + it('does not invalidate queries when buy order fails', async () => { + await withController( + async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + mockInvalidateQueries.mockClear(); - const preview = createMockOrderPreview({ side: Side.SELL }); - const params = { - preview, - }; + const preview = createMockOrderPreview({ side: Side.BUY }); - await expect(controller.placeOrder(params)).rejects.toThrow( - 'Provider error', - ); + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); - expect(DevLogger.log).toHaveBeenCalledWith( - 'PredictController: Place order failed', - expect.objectContaining({ - error: 'Provider error', - timestamp: expect.any(String), - params, - }), - ); - }); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('handle non-Error objects thrown by placeOrder', async () => { - await withController(async ({ controller }) => { - // Mock the provider to throw a non-Error object - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject('String error'), - ); - - const preview = createMockOrderPreview({ side: Side.SELL }); + it('publishes order confirmed event on successful buy order when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('PREDICT_PLACE_ORDER_FAILED'); + const preview = createMockOrderPreview({ side: Side.BUY }); - expect(controller.state.lastError).toBe('PREDICT_PLACE_ORDER_FAILED'); - }); - }); + await controller.placeOrder({ preview }); - it('pass signer with signPersonalMessage to placeOrder', async () => { - const mockTxMeta = { id: 'tx-signer-sell' } as any; - await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockResolvedValue({ - success: true as const, - response: { - id: 'sell-order-signer', - spentAmount: '100', - receivedAmount: '200', + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'confirmed', + }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, - }); + }, + ); + }); - (addTransaction as jest.Mock).mockResolvedValue({ - transactionMeta: mockTxMeta, - }); + it('does not publish order confirmed event when there is an active buy order', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PLACING_ORDER, + }); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - const preview = createMockOrderPreview({ - outcomeId: 'outcome-1', - outcomeTokenId: 'outcome-token-1', - side: Side.SELL, - }); + const preview = createMockOrderPreview({ side: Side.BUY }); - await controller.placeOrder({ - preview, - }); + await controller.placeOrder({ preview }); - // Verify that signPersonalMessage is included in the signer object - expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( - expect.objectContaining({ - signer: expect.objectContaining({ - signPersonalMessage: expect.any(Function), - signTypedMessage: expect.any(Function), - }), - }), - ); - }); + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('does not publish order event on successful sell order even when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '200', + receivedAmount: '100', + }, + }; + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + + await controller.placeOrder({ preview }); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('does not publish order event on successful buy order when predictWithAnyToken is disabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController(async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview }); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }); + }); + + it('publishes order failed event when buy order fails and there is no active buy order', async () => { + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('does not publish order failed event when buy order fails and there is an active buy order', async () => { + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PLACING_ORDER, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('handle place order errors', async () => { + await withController(async ({ controller }) => { + // Mock the provider to throw an error + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject(new Error('Order placement failed')), + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Order placement failed'); + + expect(controller.state.lastError).toBe('Order placement failed'); + }); + }); + + it('throws provider error when placeOrder returns success false', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'Order rejected by provider', + } as any); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Order rejected by provider'); + }); + }); + + it('updates state with lastError when place order fails', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject(new Error('Network error')), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Network error'); + + expect(controller.state.lastError).toBe('Network error'); + expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); + }); + }); + + it('logs error details when place order fails', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject(new Error('Provider error')), + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + const params = { + preview, + }; + + await expect(controller.placeOrder(params)).rejects.toThrow( + 'Provider error', + ); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'PredictController: Place order failed', + expect.objectContaining({ + error: 'Provider error', + timestamp: expect.any(String), + params, + }), + ); + }); + }); + + it('handle non-Error objects thrown by placeOrder', async () => { + await withController(async ({ controller }) => { + // Mock the provider to throw a non-Error object + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject('String error'), + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('PREDICT_PLACE_ORDER_FAILED'); + + expect(controller.state.lastError).toBe('PREDICT_PLACE_ORDER_FAILED'); + }); + }); + + it('pass signer with signPersonalMessage to placeOrder', async () => { + const mockTxMeta = { id: 'tx-signer-sell' } as any; + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true as const, + response: { + id: 'sell-order-signer', + spentAmount: '100', + receivedAmount: '200', + }, + }); + + (addTransaction as jest.Mock).mockResolvedValue({ + transactionMeta: mockTxMeta, + }); + + const preview = createMockOrderPreview({ + outcomeId: 'outcome-1', + outcomeTokenId: 'outcome-token-1', + side: Side.SELL, + }); + + await controller.placeOrder({ + preview, + }); + + // Verify that signPersonalMessage is included in the signer object + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + signer: expect.objectContaining({ + signPersonalMessage: expect.any(Function), + signTypedMessage: expect.any(Function), + }), + }), + ); + }); }); it('skips analytics tracking when analyticsProperties not provided', async () => { @@ -3545,29 +3957,15 @@ describe('PredictController', () => { }); describe('activeOrder and selectedPaymentToken management', () => { - it('setActiveOrder updates state with provided order', () => { - withController(({ controller }) => { - const order: PredictControllerState['activeOrder'] = { - amount: 50, - state: ActiveOrderState.PREVIEW, - }; - - controller.setActiveOrder(order); - - expect(controller.state.activeOrder).toEqual(order); - }); - }); - - it('clearActiveOrder sets activeOrder to null', () => { + it('clearActiveOrder removes activeOrder for the address', () => { withController(({ controller }) => { - controller.setActiveOrder({ - amount: 50, + setActiveOrderForTest(controller, { state: ActiveOrderState.PREVIEW, }); controller.clearActiveOrder(); - expect(controller.state.activeOrder).toBeNull(); + expect(controller.state.activeBuyOrder).toBeNull(); }); }); @@ -3600,492 +3998,578 @@ describe('PredictController', () => { }); }); - describe('payWithAnyTokenConfirmation', () => { - it('uses predict deposit transaction when setup transactions are present', async () => { - const setupTransaction = { - params: { - to: '0x1000000000000000000000000000000000000001' as `0x${string}`, - data: '0x095ea7b3000000000000000000000000' as `0x${string}`, - }, - type: TransactionType.contractInteraction, - }; - const depositTransaction = { - params: { - to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, - data: '0xa9059cbb000000000000000000000000' as `0x${string}`, - }, - type: TransactionType.predictDeposit, - }; - - mockPolymarketProvider.prepareDeposit.mockResolvedValue({ - transactions: [setupTransaction, depositTransaction], + describe('selectPaymentToken', () => { + const createAssetToken = ( + overrides: Partial<{ address: string; chainId: string; symbol: string }>, + ) => + ({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', chainId: '0x89', - }); + symbol: 'USDC.e', + decimals: 6, + image: '', + name: 'USDC.e', + balance: '0', + logo: undefined, + isETH: false, + ...overrides, + }) as any; - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'tx-pay-with-any-token', - }); + it('does nothing when token is null', () => { + withController(({ controller }) => { + const existingToken = { + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }; + controller.setSelectedPaymentToken(existingToken); - await withController(async ({ controller }) => { - const result = await controller.payWithAnyTokenConfirmation(); + controller.selectPaymentToken(null); - expect(result).toEqual({ - success: true, - response: { - batchId: 'tx-pay-with-any-token', - }, + expect(controller.state.selectedPaymentToken).toEqual(existingToken); + }); + }); + + it('sets selectedPaymentToken to null for balance placeholder address', () => { + withController(({ controller }) => { + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', }); - expect(addTransactionBatch).toHaveBeenCalledWith( - expect.objectContaining({ - from: '0x1234567890123456789012345678901234567890', - transactions: expect.arrayContaining([ - expect.objectContaining({ - type: 'predictDepositAndOrder', - }), - ]), + controller.selectPaymentToken( + createAssetToken({ + address: '0x0000000000000000000000000000000000000001', }), ); + + expect(controller.state.selectedPaymentToken).toBeNull(); }); }); - it('processes batch when no predict deposit transaction type is present', async () => { - mockPolymarketProvider.prepareDeposit.mockResolvedValue({ - transactions: [ - { - params: { - to: '0x1000000000000000000000000000000000000001' as `0x${string}`, - data: '0x095ea7b3000000000000000000000000' as `0x${string}`, - }, - type: TransactionType.contractInteraction, - }, - ], - chainId: '0x89', - }); + it('sets selectedPaymentToken for external token', () => { + withController(({ controller }) => { + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', + }), + ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-no-deposit', + expect(controller.state.selectedPaymentToken).toEqual({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', + }); }); + }); - await withController(async ({ controller }) => { - const result = await controller.payWithAnyTokenConfirmation(); - - expect(result).toEqual({ - success: true, - response: { - batchId: 'batch-no-deposit', - }, + it('clears error and transitions PAY_WITH_ANY_TOKEN to PREVIEW for balance token', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + transactionId: 'batch-123', + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + error: 'previous error', }); - expect(addTransactionBatch).toHaveBeenCalled(); + controller.selectPaymentToken( + createAssetToken({ + address: '0x0000000000000000000000000000000000000001', + }), + ); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.activeBuyOrder?.transactionId).toBe( + 'batch-123', + ); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); }); }); - }); - - describe('transactionStatusChanged event', () => { - const accountAddress = '0x1234567890123456789012345678901234567890'; - - const createPredictTransactionMeta = ({ - nestedType, - status, - batchId, - from, - }: { - nestedType: TransactionType; - status: TransactionStatus; - batchId?: string; - from?: string; - }) => - ({ - id: 'tx-1', - status, - batchId, - txParams: { - from: from ?? accountAddress, - to: '0x0000000000000000000000000000000000000001', - value: '0x0', - data: '0x', - }, - nestedTransactions: [ - { - type: nestedType, - }, - ], - }) as any; - it('publishes event for predict deposit transaction with approved status', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - batchId: 'batch-1', - from: accountAddress.toUpperCase(), + it('clears error and transitions PREVIEW to PAY_WITH_ANY_TOKEN for external token', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'old error', }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + }), ); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [accountAddress]: 'batch-1', - }; - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect(mockPolymarketProvider.prepareDeposit).not.toHaveBeenCalled(); + expect(addTransactionBatch).not.toHaveBeenCalled(); + }); + }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + it('does not change state when in PAY_WITH_ANY_TOKEN and external token selected', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'deposit', - status: 'approved', - senderAddress: accountAddress, - transactionId: 'tx-1', + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', }), ); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); }); }); - it('publishes event for deposit when pendingDeposits has placeholder before batchId is assigned', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - from: accountAddress, + it('does not change state when in PREVIEW and balance token selected', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, + controller.selectPaymentToken( + createAssetToken({ + address: '0x0000000000000000000000000000000000000001', + }), ); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [accountAddress]: 'pending', - }; - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }); + }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + it('still sets selectedPaymentToken when activeOrder is null', () => { + withController(({ controller }) => { + expect(controller.state.activeBuyOrder).toBeNull(); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'deposit', - status: 'approved', - senderAddress: accountAddress, - transactionId: 'tx-1', + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', }), ); + + expect(controller.state.selectedPaymentToken).toEqual({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', + }); }); }); + }); - it('publishes event for predict claim transaction with confirmed status', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const claimablePositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 25, - }), - createMockPosition({ - id: 'position-lost', - status: PredictPositionStatus.LOST, - currentValue: 999, - cashPnl: -999, - }), - ]; - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, + describe('clearOrderError', () => { + it('clears error from activeOrder', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'some error', }); - controller.updateStateForTesting((state) => { - state.claimablePositions = { - [accountAddress]: claimablePositions, - }; + controller.clearOrderError(); + + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + }); + }); + + it('does nothing when activeOrder has no error', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + controller.clearOrderError(); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + }); + }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'claim', - status: 'confirmed', - senderAddress: accountAddress, - transactionId: 'tx-1', - amount: 100, - }), - ); + it('does not throw when activeOrder is null', () => { + withController(({ controller }) => { + expect(controller.state.activeBuyOrder).toBeNull(); + + expect(() => controller.clearOrderError()).not.toThrow(); }); }); - it('clears only sender pending deposit when selected account differs', () => { - withController(({ controller, messenger }) => { - const selectedAddress = accountAddress; - const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.failed, - batchId: 'batch-sender', - from: senderAddress, + it('preserves other activeOrder properties', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'some error', }); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [selectedAddress]: 'batch-selected', - [senderAddress]: 'batch-sender', + controller.clearOrderError(); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }); + }); + }); + + describe('activeBuyOrder and pendingOrderPreviews', () => { + function getPendingOrderPreviews(controller: PredictController): { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; + }; + } { + return ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; + }; }; + } + ).pendingOrderPreviews; + } + + it('clearActiveOrder sets activeBuyOrder to null', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + controller.clearActiveOrder(); - expect(controller.state.pendingDeposits[selectedAddress]).toBe( - 'batch-selected', + expect(controller.state.activeBuyOrder).toBeNull(); + }); + }); + + it('clearOrderError clears error on activeBuyOrder', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'some error', + }); + + controller.clearOrderError(); + + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, ); - expect(controller.state.pendingDeposits[senderAddress]).toBeUndefined(); }); }); - it('confirms claim for sender account when selected account differs', () => { - withController(({ controller, messenger }) => { - const selectedAddress = accountAddress; - const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const senderClaimablePositions = [ - createMockPosition({ - id: 'position-sender', - status: PredictPositionStatus.WON, - currentValue: 50, - cashPnl: 10, - }), - ]; - const selectedClaimablePositions = [ - createMockPosition({ - id: 'position-selected', - status: PredictPositionStatus.WON, - currentValue: 20, - cashPnl: 5, - }), - ]; - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, - from: senderAddress, - }); - - mockPolymarketProvider.confirmClaim = jest.fn(); - - controller.updateStateForTesting((state) => { - state.claimablePositions = { - [selectedAddress]: selectedClaimablePositions, - [senderAddress]: senderClaimablePositions, - }; + it('onPlaceOrderEnd sets activeBuyOrder to null and does not clear pendingOrderPreviews', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.SUCCESS, }); + getPendingOrderPreviews(controller)['tx-123'] = { + preview: createMockOrderPreview(), + signerAddress: MOCK_ADDRESS, + }; - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + controller.onPlaceOrderEnd(); - expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ - positions: senderClaimablePositions, - signer: expect.objectContaining({ address: senderAddress }), - }); - expect(controller.state.claimablePositions[selectedAddress]).toEqual( - selectedClaimablePositions, - ); - expect(controller.state.claimablePositions[senderAddress]).toEqual([]); + expect(controller.state.activeBuyOrder).toBeNull(); + expect(getPendingOrderPreviews(controller)['tx-123']).toBeDefined(); }); }); - it('publishes event for predict withdraw transaction with failed status', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.failed, - }); + it('placeOrder in PAY_WITH_ANY_TOKEN stores preview in pendingOrderPreviews keyed by transactionId', async () => { + await withController( + async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + transactionId: 'tx-100', + }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + const preview = createMockOrderPreview({ side: Side.BUY }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + await controller.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview, + transactionId: 'tx-100', + }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'withdraw', - status: 'failed', - senderAddress: accountAddress, - transactionId: 'tx-1', - }), - ); - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.DEPOSITING, + ); + expect( + getPendingOrderPreviews(controller)['tx-100']?.preview, + ).toEqual(preview); + expect( + getPendingOrderPreviews(controller)['tx-100']?.signerAddress, + ).toBe(MOCK_ADDRESS); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('does not publish event for non-predict transactions', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.simpleSend, - status: TransactionStatus.confirmed, + it('selectPaymentToken transitions activeBuyOrder state', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'old error', }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); - - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, + controller.selectPaymentToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', } as any); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); }); }); - it('does not publish event for deposit with wrong batchId', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.confirmed, - batchId: 'batch-not-pending', - }); + it('isCurrentActiveBuyOrder returns false when activeBuyOrder has no transactionId and a transactionId is provided', async () => { + await withController( + async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-check', + spentAmount: '100', + receivedAmount: '200', + }, + }); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [accountAddress]: 'batch-expected', - }; - }); + const preview = createMockOrderPreview({ side: Side.BUY }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + await controller.placeOrder({ + preview, + transactionId: 'tx-1', + }); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); + }); - it('does not publish event when nested transactions are missing', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - }), - nestedTransactions: undefined, - }; - - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + describe('payWithAnyTokenConfirmation', () => { + it('initializes an order when there is no active order', async () => { + await withController(async ({ controller }) => { + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }); + const result = await controller.initPayWithAnyToken(); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + response: { + batchId: 'default-batch', + }, + }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.selectedPaymentToken).toBeNull(); + expect(mockPolymarketProvider.prepareDeposit).toHaveBeenCalled(); + expect(addTransactionBatch).toHaveBeenCalled(); }); }); - it('does not publish event when transaction status cannot be mapped', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, + it('reuses existing activeBuyOrder when already present', async () => { + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + transactionId: 'existing-tx', }); - transactionMeta.status = 'unapproved' as TransactionStatus; + const result = await controller.initPayWithAnyToken(); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, + expect(result.success).toBe(true); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, ); + }); + }); - controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', - }; - }); + it('uses predict deposit transaction when setup transactions are present', async () => { + const setupTransaction = { + params: { + to: '0x1000000000000000000000000000000000000001' as `0x${string}`, + data: '0x095ea7b3000000000000000000000000' as `0x${string}`, + }, + type: TransactionType.contractInteraction, + }; + const depositTransaction = { + params: { + to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, + data: '0xa9059cbb000000000000000000000000' as `0x${string}`, + }, + type: TransactionType.predictDeposit, + }; - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }); + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [setupTransaction, depositTransaction], + chainId: '0x89', + }); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'tx-pay-with-any-token', }); - }); - it('does not publish event for deposit when no pending state exists', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - batchId: 'batch-1', + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + const result = await controller.initPayWithAnyToken(); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }); + expect(result).toEqual({ + success: true, + response: { + batchId: 'tx-pay-with-any-token', + }, + }); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + from: '0x1234567890123456789012345678901234567890', + transactions: expect.arrayContaining([ + expect.objectContaining({ + type: 'predictDepositAndOrder', + }), + ]), + }), + ); }); }); - it('publishes event for pending deposit when sender address is missing', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const fallbackAddress = '0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd'; - const transactionMeta = { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - }), - txParams: { - to: '0x0000000000000000000000000000000000000001', - value: '0x0', - data: '0x', + it('processes batch when no predict deposit transaction type is present', async () => { + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [ + { + params: { + to: '0x1000000000000000000000000000000000000001' as `0x${string}`, + data: '0x095ea7b3000000000000000000000000' as `0x${string}`, + }, + type: TransactionType.contractInteraction, }, - }; + ], + chainId: '0x89', + }); - jest - .spyOn( - controller as unknown as { getEvmAccountAddress: () => string }, - 'getEvmAccountAddress', - ) - .mockReturnValue(fallbackAddress); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-no-deposit', + }); + + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + const result = await controller.initPayWithAnyToken(); + + expect(result).toEqual({ + success: true, + response: { + batchId: 'batch-no-deposit', + }, + }); + + expect(addTransactionBatch).toHaveBeenCalled(); + }); + }); + + it('clears error on activeBuyOrder after successful batch creation', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.activeBuyOrder = { + state: ActiveOrderState.PREVIEW, + error: 'previous-error', + }; + }); + + const result = await controller.initPayWithAnyToken(); + + expect(result).toEqual({ + success: true, + response: { + batchId: 'default-batch', + }, + }); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + }); + }); + }); + + describe('transactionStatusChanged event', () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + + const createPredictTransactionMeta = ({ + nestedType, + status, + batchId, + from, + }: { + nestedType: TransactionType; + status: TransactionStatus; + batchId?: string; + from?: string; + }) => + ({ + id: 'tx-1', + status, + batchId, + txParams: { + from: from ?? accountAddress, + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', + }, + nestedTransactions: [ + { + type: nestedType, + }, + ], + }) as any; + + it('publishes event for predict deposit transaction with approved status', () => { + withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, + batchId: 'batch-1', + from: accountAddress.toUpperCase(), + }); messenger.subscribe( 'PredictController:transactionStatusChanged', @@ -4094,2417 +4578,4164 @@ describe('PredictController', () => { controller.updateStateForTesting((state) => { state.pendingDeposits = { - [accountAddress]: 'pending', + [accountAddress]: 'batch-1', }; }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ type: 'deposit', status: 'approved', - senderAddress: fallbackAddress.toLowerCase(), + senderAddress: accountAddress, + transactionId: 'tx-1', }), ); }); }); - it('clears pending deposit when deposit transaction is rejected', () => { + it('publishes event for deposit when pendingDeposits has placeholder before batchId is assigned', () => { withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictDeposit, - status: TransactionStatus.rejected, - batchId: 'batch-1', + status: TransactionStatus.approved, + from: accountAddress, }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + controller.updateStateForTesting((state) => { state.pendingDeposits = { - [accountAddress]: 'batch-1', + [accountAddress]: 'pending', }; }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - expect(controller.state.pendingDeposits[accountAddress]).toBe( - undefined, + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'deposit', + status: 'approved', + senderAddress: accountAddress, + transactionId: 'tx-1', + }), ); }); }); - it('clears pending claim when claim transaction is confirmed', () => { + it('publishes event for predict claim transaction with confirmed status', () => { withController(({ controller, messenger }) => { - // Arrange + const transactionStatusChangedHandler = jest.fn(); + const claimablePositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 25, + }), + createMockPosition({ + id: 'position-lost', + + status: PredictPositionStatus.LOST, + currentValue: 999, + cashPnl: -999, + }), + ]; const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictClaim, status: TransactionStatus.confirmed, - batchId: 'claim-batch-1', }); controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', + state.claimablePositions = { + [accountAddress]: claimablePositions, }; }); - // Act + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - // Assert - expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'claim', + status: 'confirmed', + senderAddress: accountAddress, + transactionId: 'tx-1', + amount: 100, + }), + ); }); }); - it('clears pending claim when claim transaction is failed', () => { + it('clears only sender pending deposit when selected account differs', () => { withController(({ controller, messenger }) => { - // Arrange + const selectedAddress = accountAddress; + const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, + nestedType: TransactionType.predictDeposit, status: TransactionStatus.failed, - batchId: 'claim-batch-1', + batchId: 'batch-sender', + from: senderAddress, }); controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', + state.pendingDeposits = { + [selectedAddress]: 'batch-selected', + [senderAddress]: 'batch-sender', }; }); - // Act messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - // Assert - expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); + expect(controller.state.pendingDeposits[selectedAddress]).toBe( + 'batch-selected', + ); + expect(controller.state.pendingDeposits[senderAddress]).toBeUndefined(); }); }); - it('clears pending claim when claim transaction is rejected', () => { + it('confirms claim for sender account when selected account differs', () => { withController(({ controller, messenger }) => { - // Arrange + const selectedAddress = accountAddress; + const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const senderClaimablePositions = [ + createMockPosition({ + id: 'position-sender', + status: PredictPositionStatus.WON, + currentValue: 50, + cashPnl: 10, + }), + ]; + const selectedClaimablePositions = [ + createMockPosition({ + id: 'position-selected', + status: PredictPositionStatus.WON, + currentValue: 20, + cashPnl: 5, + }), + ]; const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictClaim, - status: TransactionStatus.rejected, - batchId: 'claim-batch-1', + status: TransactionStatus.confirmed, + from: senderAddress, }); + mockPolymarketProvider.confirmClaim = jest.fn(); + controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', + state.claimablePositions = { + [selectedAddress]: selectedClaimablePositions, + [senderAddress]: senderClaimablePositions, }; }); - // Act messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - // Assert - expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); + expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ + positions: senderClaimablePositions, + signer: expect.objectContaining({ address: senderAddress }), + }); + expect(controller.state.claimablePositions[selectedAddress]).toEqual( + selectedClaimablePositions, + ); + expect(controller.state.claimablePositions[senderAddress]).toEqual([]); }); }); - it('clears withdraw transaction when withdraw transaction is confirmed', () => { - withController(({ controller, messenger }) => { + it('publishes event for predict withdraw transaction with failed status', () => { + withController(({ messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.confirmed, + status: TransactionStatus.failed, }); - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - amount: 42, - chainId: 137, - transactionId: 'withdraw-1', - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: accountAddress, - }; + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as any); + + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'withdraw', + status: 'failed', + senderAddress: accountAddress, + transactionId: 'tx-1', + }), + ); + }); + }); + + it('does not publish event for non-predict transactions', () => { + withController(({ messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.simpleSend, + status: TransactionStatus.confirmed, }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - expect(controller.state.withdrawTransaction).toBeNull(); + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('keeps withdraw transaction when withdraw transaction is approved', () => { + it('does not publish event for deposit with wrong batchId', () => { withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.approved, + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + batchId: 'batch-not-pending', }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - amount: 64, - chainId: 137, - transactionId: 'withdraw-2', - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: accountAddress, + state.pendingDeposits = { + [accountAddress]: 'batch-expected', }; }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - expect(controller.state.withdrawTransaction).toEqual( - expect.objectContaining({ - transactionId: 'withdraw-2', + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + }); + }); + + it('does not publish event when nested transactions are missing', () => { + withController(({ messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, }), + nestedTransactions: undefined, + }; + + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, ); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('does not refresh balance when transaction status is approved', () => { + it('does not publish event when transaction status cannot be mapped', () => { withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictClaim, - status: TransactionStatus.approved, + status: TransactionStatus.confirmed, }); - const getBalanceSpy = jest - .spyOn(controller, 'getBalance') - .mockResolvedValue(0); + transactionMeta.status = 'unapproved' as TransactionStatus; + + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; + }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, } as { transactionMeta: TransactionMeta }); - expect(getBalanceSpy).not.toHaveBeenCalled(); + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('continues publishing when balance refresh rejects for confirmed transaction', () => { - withController(({ controller, messenger }) => { + it('does not publish event for deposit when no pending state exists', () => { + withController(({ messenger }) => { const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, + batchId: 'batch-1', }); - jest - .spyOn(controller, 'getBalance') - .mockRejectedValue(new Error('balance refresh failed')); - messenger.subscribe( 'PredictController:transactionStatusChanged', transactionStatusChangedHandler, ); - expect(() => - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }), - ).not.toThrow(); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'claim', - status: 'confirmed', - }), - ); + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('publishes event even when side effects throw', () => { + it('publishes event for pending deposit when sender address is missing', () => { withController(({ controller, messenger }) => { const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, - }); + const fallbackAddress = '0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd'; + const transactionMeta = { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, + }), + txParams: { + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', + }, + }; jest .spyOn( - controller as unknown as { - handleTransactionSideEffects: () => void; - }, - 'handleTransactionSideEffects', + controller as unknown as { getEvmAccountAddress: () => string }, + 'getEvmAccountAddress', ) - .mockImplementation(() => { - throw new Error('Side effects failed'); - }); + .mockReturnValue(fallbackAddress); messenger.subscribe( 'PredictController:transactionStatusChanged', transactionStatusChangedHandler, ); - expect(() => - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any), - ).not.toThrow(); + controller.updateStateForTesting((state) => { + state.pendingDeposits = { + [accountAddress]: 'pending', + }; + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ - type: 'claim', - status: 'confirmed', - senderAddress: accountAddress, - transactionId: 'tx-1', + type: 'deposit', + status: 'approved', + senderAddress: fallbackAddress.toLowerCase(), }), ); }); }); - it('returns undefined amount for deposit when metamaskPay values are not numeric', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears pending deposit when deposit transaction is rejected', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - const amount = getTransactionAmount({ - type: 'deposit', - status: 'confirmed', - transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.confirmed, - }), - metamaskPay: { - totalFiat: '$abc', - bridgeFeeFiat: '$1', - networkFeeFiat: '$1', - }, - }, - address: accountAddress, + controller.updateStateForTesting((state) => { + state.pendingDeposits = { + [accountAddress]: 'batch-1', + }; }); - expect(amount).toBeUndefined(); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.pendingDeposits[accountAddress]).toBe( + undefined, + ); }); }); - it('returns undefined amount for confirmed withdraw when state and receiving are not numeric', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears preview activeOrder when deposit-and-order transaction is rejected after switching back to balance', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - amount: Number.NaN, - chainId: 137, - transactionId: 'tx-1', - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: accountAddress, - }; + setActiveOrderForTest(controller, { + transactionId: 'tx-1', + state: ActiveOrderState.PREVIEW, }); - const amount = getTransactionAmount({ - type: 'withdraw', - status: 'confirmed', + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.confirmed, - }), - assetsFiatValues: { - receiving: 'not-a-number', - }, + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - address: accountAddress, - }); + } as { transactionMeta: TransactionMeta }); - expect(amount).toBeUndefined(); + expect(controller.state.activeBuyOrder).toBeNull(); }); }); - it('returns zero amount when deposit fees exceed total amount', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears activeOrder when deposit-and-order transaction is rejected from preview while an external token is still selected', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - const amount = getTransactionAmount({ - type: 'deposit', - status: 'confirmed', + setActiveOrderForTest(controller, { + transactionId: 'tx-1', + state: ActiveOrderState.PREVIEW, + }); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.confirmed, - }), - metamaskPay: { - totalFiat: 50, - bridgeFeeFiat: 30, - networkFeeFiat: 30, - }, + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - address: accountAddress, - }); + } as { transactionMeta: TransactionMeta }); - expect(amount).toBe(0); + expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.selectedPaymentToken).toBeNull(); }); }); - it('returns receiving amount for confirmed withdraw when state amount is missing', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears activeOrder when deposit-and-order transaction is rejected outside preview', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - controller.updateStateForTesting((state) => { - state.withdrawTransaction = null; + setActiveOrderForTest(controller, { + transactionId: 'tx-1', + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, }); - const amount = getTransactionAmount({ - type: 'withdraw', - status: 'confirmed', + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.confirmed, - }), - assetsFiatValues: { - receiving: '77.25', - }, + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - address: accountAddress, - }); + } as { transactionMeta: TransactionMeta }); - expect(amount).toBe(77.25); + expect(controller.state.activeBuyOrder).toBeNull(); }); }); - it('returns undefined amount for approved withdraw transaction', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears pending claim when claim transaction is confirmed', () => { + withController(({ controller, messenger }) => { + // Arrange + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.confirmed, + batchId: 'claim-batch-1', + }); - const amount = getTransactionAmount({ - type: 'withdraw', - status: 'approved', - transactionMeta: createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.approved, - }), - address: accountAddress, + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; }); - expect(amount).toBeUndefined(); + // Act + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + // Assert + expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); }); }); - it('maps transaction statuses to predict transaction event statuses', () => { - withController(({ controller }) => { - const mapStatus = ( - controller as any - ).mapTransactionStatusToPredictTransactionEventStatus.bind(controller); + it('clears pending claim when claim transaction is failed', () => { + withController(({ controller, messenger }) => { + // Arrange + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.failed, + batchId: 'claim-batch-1', + }); - expect(mapStatus(TransactionStatus.approved)).toBe('approved'); - expect(mapStatus(TransactionStatus.submitted)).toBeNull(); - expect(mapStatus(TransactionStatus.confirmed)).toBe('confirmed'); - expect(mapStatus(TransactionStatus.failed)).toBe('failed'); - expect(mapStatus(TransactionStatus.rejected)).toBe('rejected'); - }); - }); + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; + }); - it('maps transaction types to predict transaction event types', () => { - withController(({ controller }) => { - const mapType = ( - controller as any - ).mapTransactionTypeToPredictTransactionEventType.bind(controller); + // Act + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - expect(mapType(TransactionType.predictDeposit)).toBe('deposit'); - expect(mapType(TransactionType.predictClaim)).toBe('claim'); - expect(mapType(TransactionType.predictWithdraw)).toBe('withdraw'); - expect(mapType(TransactionType.swap)).toBeNull(); + // Assert + expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); }); }); - }); - - describe('getAccountState', () => { - it('successfully retrieve account state', async () => { - // Given a valid account state - const mockAccountState = { - address: '0xProxyAddress' as `0x${string}`, - isDeployed: true, - hasAllowances: true, - balance: 100.5, - }; - - mockPolymarketProvider.getAccountState.mockResolvedValue( - mockAccountState, - ); - - await withController(async ({ controller }) => { - // When calling getAccountState - const result = await controller.getAccountState({}); - // Then it should return the account state - expect(result).toEqual(mockAccountState); + it('clears pending claim when claim transaction is rejected', () => { + withController(({ controller, messenger }) => { + // Arrange + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.rejected, + batchId: 'claim-batch-1', + }); - // And provider should be called with correct owner address - expect(mockPolymarketProvider.getAccountState).toHaveBeenCalledWith({ - ownerAddress: '0x1234567890123456789012345678901234567890', + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; }); - }); - }); - it('throws provider errors when account state lookup fails', async () => { - mockPolymarketProvider.getAccountState.mockRejectedValue( - new Error('account state unavailable'), - ); + // Act + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - await withController(async ({ controller }) => { - await expect(controller.getAccountState({})).rejects.toThrow( - 'account state unavailable', - ); + // Assert + expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); }); }); - }); - describe('getBalance', () => { - it('get balance successfully with default address', async () => { - // Given - const mockBalance = 1000; - mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); - - await withController(async ({ controller }) => { - // When calling getBalance - const result = await controller.getBalance({}); + it('clears withdraw transaction when withdraw transaction is confirmed', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.confirmed, + }); - // Then it should return the balance - expect(result).toBe(mockBalance); - - // And provider should be called with default address - expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ - address: '0x1234567890123456789012345678901234567890', - }); - }); - }); - - it('get balance successfully with custom address', async () => { - // Given - const mockBalance = 500; - mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); - - await withController(async ({ controller }) => { - // When calling getBalance with custom address - const result = await controller.getBalance({ - address: '0x9876543210987654321098765432109876543210', - }); - - // Then it should return the balance - expect(result).toBe(mockBalance); - - // And provider should be called with custom address - expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ - address: '0x9876543210987654321098765432109876543210', + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + amount: 42, + chainId: 137, + transactionId: 'withdraw-1', + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: accountAddress, + }; }); - }); - }); - it('handle error when getBalance throws', async () => { - // Given - mockPolymarketProvider.getBalance.mockRejectedValue( - new Error('Balance fetch failed'), - ); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - await withController(async ({ controller }) => { - // When calling getBalance - // Then it should throw an error - await expect(controller.getBalance({})).rejects.toThrow( - 'Balance fetch failed', - ); + expect(controller.state.withdrawTransaction).toBeNull(); }); }); - }); - - describe('previewOrder', () => { - it('previews order successfully', async () => { - const mockOrderPreview = createMockOrderPreview({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - }); - mockPolymarketProvider.previewOrder.mockResolvedValue(mockOrderPreview); + it('keeps withdraw transaction when withdraw transaction is approved', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.approved, + }); - await withController(async ({ controller }) => { - const result = await controller.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + amount: 64, + chainId: 137, + transactionId: 'withdraw-2', + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: accountAddress, + }; }); - expect(result).toEqual(mockOrderPreview); - expect(mockPolymarketProvider.previewOrder).toHaveBeenCalledWith( + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.withdrawTransaction).toEqual( expect.objectContaining({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - signer: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: expect.any(Function), - signPersonalMessage: expect.any(Function), - }), + transactionId: 'withdraw-2', }), ); - - const signer = mockPolymarketProvider.previewOrder.mock.calls[0][0] - .signer as { - signTypedMessage: ( - params: unknown, - version: unknown, - ) => Promise; - signPersonalMessage: (params: unknown) => Promise; - }; - await signer.signTypedMessage({} as never, 'V4' as never); - await signer.signPersonalMessage({} as never); }); }); - it('handles preview errors', async () => { - mockPolymarketProvider.previewOrder.mockRejectedValue( - new Error('Preview failed'), - ); + it('does not refresh balance when transaction status is approved', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.approved, + }); - await withController(async ({ controller }) => { - await expect( - controller.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }), - ).rejects.toThrow('Preview failed'); - }); - }); + const getBalanceSpy = jest + .spyOn(controller, 'getBalance') + .mockResolvedValue(0); - it('handles synchronous preview errors thrown by provider', async () => { - mockPolymarketProvider.previewOrder.mockImplementation(() => { - throw new Error('Preview failed synchronously'); - }); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - await withController(async ({ controller }) => { - await expect( - controller.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }), - ).rejects.toThrow('Preview failed synchronously'); + expect(getBalanceSpy).not.toHaveBeenCalled(); }); }); - }); - - describe('prepareWithdraw', () => { - const mockWithdrawResponse = { - chainId: '0x89' as `0x${string}`, - transaction: { - params: { - to: '0xWithdrawAddress' as `0x${string}`, - data: '0xwithdrawdata' as `0x${string}`, - }, - }, - predictAddress: '0xPredictAddress' as `0x${string}`, - }; - - it('successfully prepare withdraw transaction', async () => { - const mockBatchId = 'withdraw-batch-1'; - - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: mockBatchId, - }); - await withController(async ({ controller }) => { - const result = await controller.prepareWithdraw({}); - - expect(result.success).toBe(true); - expect(result.response).toBe(mockBatchId); - expect(controller.state.withdrawTransaction).toEqual({ - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredictAddress', - transactionId: mockBatchId, - amount: 0, + it('continues publishing when balance refresh rejects for confirmed transaction', () => { + withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.confirmed, }); - }); - }); - it('updates state with lastError when prepare withdraw fails', async () => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('Provider error'), - ); + jest + .spyOn(controller, 'getBalance') + .mockRejectedValue(new Error('balance refresh failed')); - await withController(async ({ controller }) => { - await expect(controller.prepareWithdraw({})).rejects.toThrow( - 'Provider error', + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, ); - expect(controller.state.lastError).toBe('Provider error'); - expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); - expect(controller.state.withdrawTransaction).toBeNull(); - }); - }); - - it('logs error details when prepare withdraw fails', async () => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('Network error'), - ); - - await withController(async ({ controller }) => { - await expect(controller.prepareWithdraw({})).rejects.toThrow( - 'Network error', - ); + expect(() => + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }), + ).not.toThrow(); - expect(DevLogger.log).toHaveBeenCalledWith( - 'PredictController: Prepare withdraw failed', + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ - error: 'Network error', - timestamp: expect.any(String), + type: 'claim', + status: 'confirmed', }), ); }); }); - it('call provider prepareWithdraw with correct signer', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-test', - }); + it('publishes event even when side effects throw', () => { + withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.confirmed, + }); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); - - expect(mockPolymarketProvider.prepareWithdraw).toHaveBeenCalledWith({ - signer: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: expect.any(Function), - signPersonalMessage: expect.any(Function), - }), - }); - }); - }); + jest + .spyOn( + controller as unknown as { + handleTransactionSideEffects: () => void; + }, + 'handleTransactionSideEffects', + ) + .mockImplementation(() => { + throw new Error('Side effects failed'); + }); - it('call addTransactionBatch with correct parameters', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-tx', - }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); + expect(() => + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as any), + ).not.toThrow(); - expect(addTransactionBatch).toHaveBeenCalledWith( + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ - from: '0x1234567890123456789012345678901234567890', - origin: 'metamask', - networkClientId: expect.any(String), - disableHook: true, - disableSequential: true, - requireApproval: true, - transactions: [mockWithdrawResponse.transaction], + type: 'claim', + status: 'confirmed', + senderAddress: accountAddress, + transactionId: 'tx-1', }), ); }); }); - it('update transaction ID when batch ID is returned', async () => { - const mockBatchId = 'tx-batch-update'; + it('returns undefined amount for deposit when metamaskPay values are not numeric', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: mockBatchId, + const amount = getTransactionAmount({ + type: 'deposit', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }), + metamaskPay: { + totalFiat: '$abc', + bridgeFeeFiat: '$1', + networkFeeFiat: '$1', + }, + }, + address: accountAddress, + }); + + expect(amount).toBeUndefined(); }); + }); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); + it('returns undefined amount for confirmed withdraw when state and receiving are not numeric', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - expect(controller.state.withdrawTransaction?.transactionId).toBe( - mockBatchId, - ); + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + amount: Number.NaN, + chainId: 137, + transactionId: 'tx-1', + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: accountAddress, + }; + }); + + const amount = getTransactionAmount({ + type: 'withdraw', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.confirmed, + }), + assetsFiatValues: { + receiving: 'not-a-number', + }, + }, + address: accountAddress, + }); + + expect(amount).toBeUndefined(); }); }); - it('returns error when addTransactionBatch fails', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockRejectedValue( - new Error('Transaction batch submission failed'), - ); + it('returns zero amount when deposit fees exceed total amount', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - await withController(async ({ controller }) => { - await expect(controller.prepareWithdraw({})).rejects.toThrow( - 'Transaction batch submission failed', - ); + const amount = getTransactionAmount({ + type: 'deposit', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }), + metamaskPay: { + totalFiat: 50, + bridgeFeeFiat: 30, + networkFeeFiat: 30, + }, + }, + address: accountAddress, + }); - expect(controller.state.lastError).toBe( - 'Transaction batch submission failed', - ); + expect(amount).toBe(0); }); }); - it('store withdraw transaction state before creating batch', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-123', - }); + it('returns receiving amount for confirmed withdraw when state amount is missing', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - await withController(async ({ controller }) => { - expect(controller.state.withdrawTransaction).toBeNull(); + controller.updateStateForTesting((state) => { + state.withdrawTransaction = null; + }); - await controller.prepareWithdraw({}); + const amount = getTransactionAmount({ + type: 'withdraw', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.confirmed, + }), + assetsFiatValues: { + receiving: '77.25', + }, + }, + address: accountAddress, + }); - expect(controller.state.withdrawTransaction).toBeDefined(); - expect(controller.state.withdrawTransaction?.status).toBe( - PredictWithdrawStatus.IDLE, - ); - expect(controller.state.withdrawTransaction?.chainId).toBe(137); + expect(amount).toBe(77.25); }); }); - it('convert hex chainId to number in state', async () => { - const customChainId = '0x1' as `0x${string}`; - mockPolymarketProvider.prepareWithdraw.mockResolvedValue({ - ...mockWithdrawResponse, - chainId: customChainId, - }); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-chain', - }); + it('returns undefined amount for approved withdraw transaction', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); + const amount = getTransactionAmount({ + type: 'withdraw', + status: 'approved', + transactionMeta: createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.approved, + }), + address: accountAddress, + }); - expect(controller.state.withdrawTransaction?.chainId).toBe(1); + expect(amount).toBeUndefined(); }); }); - it('return success when user denies transaction signature', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('User denied transaction signature'), - ); - - const result = await controller.prepareWithdraw({}); - - expect(result.success).toBe(true); - expect(result.response).toBe('User cancelled transaction'); - }); - }); - - it('return success when user denial error is wrapped in message', async () => { - await withController(async ({ controller }) => { - (addTransactionBatch as jest.Mock).mockRejectedValue( - new Error( - 'Transaction failed: User denied transaction signature - action cancelled', - ), - ); - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - - const result = await controller.prepareWithdraw({}); + it('maps transaction statuses to predict transaction event statuses', () => { + withController(({ controller }) => { + const mapStatus = ( + controller as any + ).mapTransactionStatusToPredictTransactionEventStatus.bind(controller); - expect(result.success).toBe(true); - expect(result.response).toBe('User cancelled transaction'); + expect(mapStatus(TransactionStatus.approved)).toBe('approved'); + expect(mapStatus(TransactionStatus.submitted)).toBeNull(); + expect(mapStatus(TransactionStatus.confirmed)).toBe('confirmed'); + expect(mapStatus(TransactionStatus.failed)).toBe('failed'); + expect(mapStatus(TransactionStatus.rejected)).toBe('rejected'); }); }); - it('not update state when user cancels transaction', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('User denied transaction signature'), - ); - - await controller.prepareWithdraw({}); + it('maps transaction types to predict transaction event types', () => { + withController(({ controller }) => { + const mapType = ( + controller as any + ).mapTransactionTypeToPredictTransactionEventType.bind(controller); - expect(controller.state.lastError).toBeNull(); - expect(controller.state.withdrawTransaction).toBeNull(); + expect(mapType(TransactionType.predictDeposit)).toBe('deposit'); + expect(mapType(TransactionType.predictClaim)).toBe('claim'); + expect(mapType(TransactionType.predictWithdraw)).toBe('withdraw'); + expect(mapType(TransactionType.swap)).toBeNull(); }); }); }); - describe('beforeSign', () => { - const mockTransactionMeta = { - id: 'tx-1', - txParams: { - from: '0x1234567890123456789012345678901234567890', - to: '0xTarget', - data: '0xdata', - value: '0x0', - }, - nestedTransactions: [ - { - id: 'nested-1', - type: 'predictWithdraw' as const, - data: '0xoriginaldata' as `0x${string}`, - }, - ], - }; + describe('getAccountState', () => { + it('successfully retrieve account state', async () => { + // Given a valid account state + const mockAccountState = { + address: '0xProxyAddress' as `0x${string}`, + isDeployed: true, + hasAllowances: true, + balance: 100.5, + }; - beforeEach(() => { - mockPolymarketProvider.signWithdraw = jest.fn(); - }); + mockPolymarketProvider.getAccountState.mockResolvedValue( + mockAccountState, + ); - it('return undefined when no withdraw transaction in state', async () => { await withController(async ({ controller }) => { - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); + // When calling getAccountState + const result = await controller.getAccountState({}); - expect(result).toBeUndefined(); - }); - }); + // Then it should return the account state + expect(result).toEqual(mockAccountState); - it('return undefined when transaction is not a withdraw transaction', async () => { - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; + // And provider should be called with correct owner address + expect(mockPolymarketProvider.getAccountState).toHaveBeenCalledWith({ + ownerAddress: '0x1234567890123456789012345678901234567890', }); + }); + }); - const nonWithdrawTx = { - ...mockTransactionMeta, - nestedTransactions: [ - { - id: 'nested-1', - type: 'otherType' as const, - data: '0xdata' as `0x${string}`, - }, - ], - }; - - const result = await controller.beforeSign({ - transactionMeta: nonWithdrawTx as any, - }); + it('throws provider errors when account state lookup fails', async () => { + mockPolymarketProvider.getAccountState.mockRejectedValue( + new Error('account state unavailable'), + ); - expect(result).toBeUndefined(); + await withController(async ({ controller }) => { + await expect(controller.getAccountState({})).rejects.toThrow( + 'account state unavailable', + ); }); }); + }); + + describe('getBalance', () => { + it('get balance successfully with default address', async () => { + // Given + const mockBalance = 1000; + mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); - it('return undefined when provider does not support signWithdraw', async () => { await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); + // When calling getBalance + const result = await controller.getBalance({}); - delete (mockPolymarketProvider as any).signWithdraw; + // Then it should return the balance + expect(result).toBe(mockBalance); - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, + // And provider should be called with default address + expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ + address: '0x1234567890123456789012345678901234567890', }); - - expect(result).toBeUndefined(); }); }); - it('call prepareWithdrawConfirmation with correct parameters', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xnewdata' as `0x${string}`, - amount: 100, - }); + it('get balance successfully with custom address', async () => { + // Given + const mockBalance = 500; + mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; + // When calling getBalance with custom address + const result = await controller.getBalance({ + address: '0x9876543210987654321098765432109876543210', }); - await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); + // Then it should return the balance + expect(result).toBe(mockBalance); - expect(mockPolymarketProvider.signWithdraw).toHaveBeenCalledWith({ - callData: '0xoriginaldata', - signer: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: expect.any(Function), - signPersonalMessage: expect.any(Function), - }), + // And provider should be called with custom address + expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ + address: '0x9876543210987654321098765432109876543210', }); }); }); - it('update withdraw transaction amount and status', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xnewdata' as `0x${string}`, - amount: 250.5, - }); + it('handle error when getBalance throws', async () => { + // Given + mockPolymarketProvider.getBalance.mockRejectedValue( + new Error('Balance fetch failed'), + ); await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); - - expect(controller.state.withdrawTransaction?.amount).toBe(250.5); - expect(controller.state.withdrawTransaction?.status).toBe( - PredictWithdrawStatus.PENDING, + // When calling getBalance + // Then it should throw an error + await expect(controller.getBalance({})).rejects.toThrow( + 'Balance fetch failed', ); }); }); + }); - it('return updateTransaction function that modifies transaction data', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xmodifieddata' as `0x${string}`, - amount: 100, + describe('previewOrder', () => { + it('previews order successfully', async () => { + const mockOrderPreview = createMockOrderPreview({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, }); + mockPolymarketProvider.previewOrder.mockResolvedValue(mockOrderPreview); + await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredictAddress' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; + const result = await controller.previewOrder({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, }); - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, + expect(result).toEqual(mockOrderPreview); + expect(mockPolymarketProvider.previewOrder).toHaveBeenCalledWith( + expect.objectContaining({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, + signer: expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: expect.any(Function), + signPersonalMessage: expect.any(Function), + }), + }), + ); + + const signer = mockPolymarketProvider.previewOrder.mock.calls[0][0] + .signer as { + signTypedMessage: ( + params: unknown, + version: unknown, + ) => Promise; + signPersonalMessage: (params: unknown) => Promise; + }; + await signer.signTypedMessage({} as never, 'V4' as never); + await signer.signPersonalMessage({} as never); + }); + }); + + it('handles preview errors', async () => { + mockPolymarketProvider.previewOrder.mockRejectedValue( + new Error('Preview failed'), + ); + + await withController(async ({ controller }) => { + await expect( + controller.previewOrder({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, + }), + ).rejects.toThrow('Preview failed'); + }); + }); + + it('handles synchronous preview errors thrown by provider', async () => { + mockPolymarketProvider.previewOrder.mockImplementation(() => { + throw new Error('Preview failed synchronously'); + }); + + await withController(async ({ controller }) => { + await expect( + controller.previewOrder({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, + }), + ).rejects.toThrow('Preview failed synchronously'); + }); + }); + }); + + describe('prepareWithdraw', () => { + const mockWithdrawResponse = { + chainId: '0x89' as `0x${string}`, + transaction: { + params: { + to: '0xWithdrawAddress' as `0x${string}`, + data: '0xwithdrawdata' as `0x${string}`, + }, + }, + predictAddress: '0xPredictAddress' as `0x${string}`, + }; + + it('successfully prepare withdraw transaction', async () => { + const mockBatchId = 'withdraw-batch-1'; + + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: mockBatchId, + }); + + await withController(async ({ controller }) => { + const result = await controller.prepareWithdraw({}); + + expect(result.success).toBe(true); + expect(result.response).toBe(mockBatchId); + expect(controller.state.withdrawTransaction).toEqual({ + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredictAddress', + transactionId: mockBatchId, + amount: 0, }); + }); + }); - expect(result).toBeDefined(); - expect(result?.updateTransaction).toBeDefined(); + it('updates state with lastError when prepare withdraw fails', async () => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('Provider error'), + ); - const testTransaction: { - txParams: { - from: string; - to: string; - data: string; - gas?: string; - gasLimit?: string; - }; - assetsFiatValues?: { - receiving?: string; - sending?: string; - }; - } = { - txParams: { - from: '0xFrom', - to: '0xOldTarget', - data: '0xolddata', + await withController(async ({ controller }) => { + await expect(controller.prepareWithdraw({})).rejects.toThrow( + 'Provider error', + ); + + expect(controller.state.lastError).toBe('Provider error'); + expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('logs error details when prepare withdraw fails', async () => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('Network error'), + ); + + await withController(async ({ controller }) => { + await expect(controller.prepareWithdraw({})).rejects.toThrow( + 'Network error', + ); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'PredictController: Prepare withdraw failed', + expect.objectContaining({ + error: 'Network error', + timestamp: expect.any(String), + }), + ); + }); + }); + + it('call provider prepareWithdraw with correct signer', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-test', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(mockPolymarketProvider.prepareWithdraw).toHaveBeenCalledWith({ + signer: expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: expect.any(Function), + signPersonalMessage: expect.any(Function), + }), + }); + }); + }); + + it('call addTransactionBatch with correct parameters', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-tx', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + from: '0x1234567890123456789012345678901234567890', + origin: 'metamask', + networkClientId: expect.any(String), + disableHook: true, + disableSequential: true, + requireApproval: true, + transactions: [mockWithdrawResponse.transaction], + }), + ); + }); + }); + + it('update transaction ID when batch ID is returned', async () => { + const mockBatchId = 'tx-batch-update'; + + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: mockBatchId, + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(controller.state.withdrawTransaction?.transactionId).toBe( + mockBatchId, + ); + }); + }); + + it('returns error when addTransactionBatch fails', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockRejectedValue( + new Error('Transaction batch submission failed'), + ); + + await withController(async ({ controller }) => { + await expect(controller.prepareWithdraw({})).rejects.toThrow( + 'Transaction batch submission failed', + ); + + expect(controller.state.lastError).toBe( + 'Transaction batch submission failed', + ); + }); + }); + + it('store withdraw transaction state before creating batch', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-123', + }); + + await withController(async ({ controller }) => { + expect(controller.state.withdrawTransaction).toBeNull(); + + await controller.prepareWithdraw({}); + + expect(controller.state.withdrawTransaction).toBeDefined(); + expect(controller.state.withdrawTransaction?.status).toBe( + PredictWithdrawStatus.IDLE, + ); + expect(controller.state.withdrawTransaction?.chainId).toBe(137); + }); + }); + + it('convert hex chainId to number in state', async () => { + const customChainId = '0x1' as `0x${string}`; + mockPolymarketProvider.prepareWithdraw.mockResolvedValue({ + ...mockWithdrawResponse, + chainId: customChainId, + }); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-chain', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(controller.state.withdrawTransaction?.chainId).toBe(1); + }); + }); + + it('return success when user denies transaction signature', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('User denied transaction signature'), + ); + + const result = await controller.prepareWithdraw({}); + + expect(result.success).toBe(true); + expect(result.response).toBe('User cancelled transaction'); + }); + }); + + it('return success when user denial error is wrapped in message', async () => { + await withController(async ({ controller }) => { + (addTransactionBatch as jest.Mock).mockRejectedValue( + new Error( + 'Transaction failed: User denied transaction signature - action cancelled', + ), + ); + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + + const result = await controller.prepareWithdraw({}); + + expect(result.success).toBe(true); + expect(result.response).toBe('User cancelled transaction'); + }); + }); + + it('not update state when user cancels transaction', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('User denied transaction signature'), + ); + + await controller.prepareWithdraw({}); + + expect(controller.state.lastError).toBeNull(); + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + }); + + describe('beforeSign', () => { + const mockTransactionMeta = { + id: 'tx-1', + txParams: { + from: '0x1234567890123456789012345678901234567890', + to: '0xTarget', + data: '0xdata', + value: '0x0', + }, + nestedTransactions: [ + { + id: 'nested-1', + type: 'predictWithdraw' as const, + data: '0xoriginaldata' as `0x${string}`, + }, + ], + }; + + beforeEach(() => { + mockPolymarketProvider.signWithdraw = jest.fn(); + }); + + it('return undefined when no withdraw transaction in state', async () => { + await withController(async ({ controller }) => { + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('return undefined when transaction is not a withdraw transaction', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const nonWithdrawTx = { + ...mockTransactionMeta, + nestedTransactions: [ + { + id: 'nested-1', + type: 'otherType' as const, + data: '0xdata' as `0x${string}`, + }, + ], + }; + + const result = await controller.beforeSign({ + transactionMeta: nonWithdrawTx as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('return undefined when provider does not support signWithdraw', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + delete (mockPolymarketProvider as any).signWithdraw; + + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('call prepareWithdrawConfirmation with correct parameters', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xnewdata' as `0x${string}`, + amount: 100, + }); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(mockPolymarketProvider.signWithdraw).toHaveBeenCalledWith({ + callData: '0xoriginaldata', + signer: expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: expect.any(Function), + signPersonalMessage: expect.any(Function), + }), + }); + }); + }); + + it('update withdraw transaction amount and status', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xnewdata' as `0x${string}`, + amount: 250.5, + }); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(controller.state.withdrawTransaction?.amount).toBe(250.5); + expect(controller.state.withdrawTransaction?.status).toBe( + PredictWithdrawStatus.PENDING, + ); + }); + }); + + it('return updateTransaction function that modifies transaction data', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xmodifieddata' as `0x${string}`, + amount: 100, + }); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredictAddress' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeDefined(); + expect(result?.updateTransaction).toBeDefined(); + + const testTransaction: { + txParams: { + from: string; + to: string; + data: string; + gas?: string; + gasLimit?: string; + }; + assetsFiatValues?: { + receiving?: string; + sending?: string; + }; + } = { + txParams: { + from: '0xFrom', + to: '0xOldTarget', + data: '0xolddata', + }, + }; + + result?.updateTransaction?.(testTransaction as any); + + expect(testTransaction.txParams.data).toBe('0xmodifieddata'); + expect(testTransaction.txParams.to).toBe('0xPredictAddress'); + expect(testTransaction.assetsFiatValues).toEqual({ + receiving: '100', + }); + }); + }); + + it('throw error when prepareWithdrawConfirmation fails', async () => { + mockPolymarketProvider.signWithdraw?.mockRejectedValue( + new Error('Confirmation preparation failed'), + ); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + await expect( + controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }), + ).rejects.toThrow('Confirmation preparation failed'); + }); + }); + + it('sets withdraw transaction status to error when gas estimation fails', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xnewdata' as `0x${string}`, + amount: 100, + }); + + await withController( + async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeUndefined(); + expect(controller.state.withdrawTransaction?.status).toBe( + PredictWithdrawStatus.ERROR, + ); + }, + { + mocks: { + estimateGas: jest + .fn() + .mockRejectedValue(new Error('Gas estimation failed')), + }, + }, + ); + }); + + it('return undefined when nestedTransactions is undefined', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const txWithoutNested = { + ...mockTransactionMeta, + nestedTransactions: undefined, + }; + + const result = await controller.beforeSign({ + transactionMeta: txWithoutNested as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('return undefined when nestedTransactions is empty array', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const txWithEmptyNested = { + ...mockTransactionMeta, + nestedTransactions: [], + }; + + const result = await controller.beforeSign({ + transactionMeta: txWithEmptyNested as any, + }); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('clearWithdrawTransaction', () => { + it('clear withdraw transaction from state', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-123', + amount: 100, + }; + }); + + expect(controller.state.withdrawTransaction).toEqual({ + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict', + transactionId: 'tx-123', + amount: 100, + }); + + controller.clearWithdrawTransaction(); + + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('handle clearing when withdraw transaction is already null', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = null; + }); + + expect(() => controller.clearWithdrawTransaction()).not.toThrow(); + + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('clear withdraw transaction with pending status', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-456', + amount: 500, + }; + }); + + controller.clearWithdrawTransaction(); + + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('clear withdraw transaction does not affect other state properties', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-789', + amount: 200, + }; + state.eligibility = { eligible: true, country: 'PT' }; + state.lastError = 'Some error'; + }); + + const originalEligibility = controller.state.eligibility; + const originalLastError = controller.state.lastError; + + controller.clearWithdrawTransaction(); + + expect(controller.state.withdrawTransaction).toBeNull(); + expect(controller.state.eligibility).toEqual(originalEligibility); + expect(controller.state.lastError).toBe(originalLastError); + }); + }); + }); + + describe('confirmClaim', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('clears claimable positions from state after confirmation', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + // Set up state with claimable positions + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert + expect(controller.state.claimablePositions[testAddress]).toEqual([]); + }); + }); + + it('calls provider confirmClaim with correct positions', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + ]; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ + positions: mockPositions, + signer: expect.objectContaining({ + address: testAddress, + }), + }); + }); + }); + + it('returns early when no claimable positions exist', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = []; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + }); + }); + + it('returns early when claimable positions undefined for address', async () => { + // Arrange + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.claimablePositions = {}; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ + address: '0x1234567890123456789012345678901234567890', + }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + }); + }); + + it('handles provider without confirmClaim method', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + ]; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + // Remove confirmClaim method from provider + delete (mockPolymarketProvider as { confirmClaim?: unknown }) + .confirmClaim; + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert - should not throw, state should still be cleared + expect(controller.state.claimablePositions[testAddress]).toEqual([]); + }); + }); + }); + + describe('getPositions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('defaults to polymarket provider when no providerId specified', async () => { + // Arrange + await withController(async ({ controller }) => { + const mockPositions = [ + { + id: 'position-1', + marketId: 'market-1', + status: PredictPositionStatus.OPEN, + currentValue: 100, + cashPnl: 0, + }, + ]; + + mockPolymarketProvider.getPositions = jest + .fn() + .mockResolvedValue(mockPositions); + + // Act + const result = await controller.getPositions({ + address: '0x1234567890123456789012345678901234567890', + }); + + // Assert + expect(result).toEqual(mockPositions); + expect(mockPolymarketProvider.getPositions).toHaveBeenCalled(); + }); + }); + + it('stores claimable positions keyed by address', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockClaimablePositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + mockPolymarketProvider.getPositions = jest + .fn() + .mockResolvedValue(mockClaimablePositions); + + // Act + await controller.getPositions({ + address: testAddress, + claimable: true, + }); + + // Assert + expect(controller.state.claimablePositions[testAddress]).toHaveLength( + 2, + ); + expect(controller.state.claimablePositions[testAddress]).toEqual( + mockClaimablePositions, + ); + }); + }); + }); + + describe('invalidateQueryCache', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls NetworkController.findNetworkClientIdByChainId with hex chain ID', async () => { + const mockFindNetworkClientIdByChainId = jest + .fn() + .mockReturnValue('polygon-mainnet'); + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0x89'); + }, + { + mocks: { + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + }, + }, + ); + }); + + it('calls NetworkController.getNetworkClientById with network client ID', async () => { + const mockGetNetworkClientById = jest + .fn() + .mockReturnValue(DEFAULT_NETWORK_CLIENT); + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(mockGetNetworkClientById).toHaveBeenCalledWith( + 'polygon-mainnet', + ); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + + it('calls blockTracker.checkForLatestBlock to invalidate cache', async () => { + const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(mockCheckForLatestBlock).toHaveBeenCalledWith(); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + + it('logs error when blockTracker.checkForLatestBlock fails', async () => { + const mockError = new Error('Block tracker error'); + const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(DevLogger.log).toHaveBeenCalledWith( + 'PredictController: Error invalidating query cache', + expect.objectContaining({ + error: 'Block tracker error', + timestamp: expect.any(String), + }), + ); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + + it('continues execution when invalidation fails', async () => { + const mockError = new Error('Block tracker error'); + const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act & Assert - should not throw + await expect( + // eslint-disable-next-line dot-notation + controller['invalidateQueryCache'](chainId), + ).resolves.not.toThrow(); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + }); + + describe('placeOrder - optimistic balance updates', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('decreases balance by spent amount for BUY orders', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100.50', + receivedAmount: '200', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(1000 - 100.5); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('sets validUntil to 5 seconds in future for BUY orders', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '50', + receivedAmount: '100', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const now = Date.now(); + jest.setSystemTime(now); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.validUntil).toBe(now + 5000); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance(), + }, + }, + }, + ); + }); + + it('increases balance by received amount for SELL orders', async () => { + const preview = createMockOrderPreview({ side: Side.SELL }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '95.50', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(500 + 95.5); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 500 }), + }, + }, + }, + ); + }); + + it('sets validUntil to 5 seconds in future for SELL orders', async () => { + const preview = createMockOrderPreview({ side: Side.SELL }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '50', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const now = Date.now(); + jest.setSystemTime(now); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.validUntil).toBe(now + 5000); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance(), + }, + }, + }, + ); + }); + + it('results in NaN balance when parsing invalid spentAmount', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: 'invalid', + receivedAmount: '100', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert - parseFloat('invalid') returns NaN + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBeNaN(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('does not update balance when provider.placeOrder fails', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + await withController( + async ({ controller }) => { + // Act + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Order failed'); + + // Assert - balance should remain unchanged + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(1000); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('results in NaN balance when spentAmount is empty for BUY orders', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '', + receivedAmount: '100', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert - parseFloat('') returns NaN, so balance becomes NaN + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBeNaN(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('results in NaN balance when receivedAmount is empty for SELL orders', async () => { + const preview = createMockOrderPreview({ side: Side.SELL }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert - parseFloat('') returns NaN, so balance becomes NaN + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBeNaN(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 500 }), + }, }, - }; + }, + ); + }); + }); - result?.updateTransaction?.(testTransaction as any); + describe('getBalance - caching behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); - expect(testTransaction.txParams.data).toBe('0xmodifieddata'); - expect(testTransaction.txParams.to).toBe('0xPredictAddress'); - expect(testTransaction.assetsFiatValues).toEqual({ - receiving: '100', - }); - }); + afterEach(() => { + jest.useRealTimers(); }); - it('throw error when prepareWithdrawConfirmation fails', async () => { - mockPolymarketProvider.signWithdraw?.mockRejectedValue( - new Error('Confirmation preparation failed'), - ); + it('returns cached balance when validUntil is in future', async () => { + const now = Date.now(); + jest.setSystemTime(now); - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); + await withController( + async ({ controller }) => { + // Act + const result = await controller.getBalance({}); - await expect( - controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }), - ).rejects.toThrow('Confirmation preparation failed'); - }); + // Assert + expect(result).toBe(1500); + expect(mockPolymarketProvider.getBalance).not.toHaveBeenCalled(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now + 500, + }), + }, + }, + }, + ); }); - it('sets withdraw transaction status to error when gas estimation fails', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xnewdata' as `0x${string}`, - amount: 100, - }); + it('fetches fresh balance when cache expired', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(2000); await withController( async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); + // Act + const result = await controller.getBalance({}); - expect(result).toBeUndefined(); - expect(controller.state.withdrawTransaction?.status).toBe( - PredictWithdrawStatus.ERROR, - ); + // Assert + expect(result).toBe(2000); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); }, { - mocks: { - estimateGas: jest - .fn() - .mockRejectedValue(new Error('Gas estimation failed')), + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now - 100, + }), + }, }, }, ); }); - it('return undefined when nestedTransactions is undefined', async () => { - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - const txWithoutNested = { - ...mockTransactionMeta, - nestedTransactions: undefined, - }; + it('fetches fresh balance when no cached balance exists', async () => { + mockPolymarketProvider.getBalance.mockResolvedValue(1000); - const result = await controller.beforeSign({ - transactionMeta: txWithoutNested as any, - }); + await withController(async ({ controller }) => { + // Act + const result = await controller.getBalance({}); - expect(result).toBeUndefined(); + // Assert + expect(result).toBe(1000); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); }); }); - it('return undefined when nestedTransactions is empty array', async () => { - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - const txWithEmptyNested = { - ...mockTransactionMeta, - nestedTransactions: [], - }; + it('updates cache with validUntil 1 second in future after fetch', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(2500); - const result = await controller.beforeSign({ - transactionMeta: txWithEmptyNested as any, - }); + await withController(async ({ controller }) => { + // Act + await controller.getBalance({}); - expect(result).toBeUndefined(); + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(2500); + expect(updatedBalance.validUntil).toBe(now + 1000); }); }); - }); - describe('clearWithdrawTransaction', () => { - it('clear withdraw transaction from state', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-123', - amount: 100, - }; - }); + it('calls invalidateQueryCache before fetching fresh balance', async () => { + const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + mockPolymarketProvider.getBalance.mockResolvedValue(1000); - expect(controller.state.withdrawTransaction).toEqual({ - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict', - transactionId: 'tx-123', - amount: 100, - }); + await withController( + async ({ controller }) => { + // Act + await controller.getBalance({}); - controller.clearWithdrawTransaction(); + // Assert + expect(mockCheckForLatestBlock).toHaveBeenCalled(); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); - expect(controller.state.withdrawTransaction).toBeNull(); - }); + it('fetches balance when validUntil equals current time', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(1800); + + await withController( + async ({ controller }) => { + // Act + const result = await controller.getBalance({}); + + // Assert + expect(result).toBe(1800); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now, + }), + }, + }, + }, + ); }); - it('handle clearing when withdraw transaction is already null', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = null; - }); + it('caches balance per providerId and address combination', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(3000); - expect(() => controller.clearWithdrawTransaction()).not.toThrow(); + await withController( + async ({ controller }) => { + // Act - fetch for different address + const result = await controller.getBalance({ + address: '0xdifferentaddress000000000000000000000000', + }); - expect(controller.state.withdrawTransaction).toBeNull(); - }); + // Assert + expect(result).toBe(3000); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + // Original cached balance should still exist + expect( + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ].balance, + ).toBe(1500); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now + 500, + }), + }, + }, + }, + ); }); + }); - it('clear withdraw transaction with pending status', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-456', - amount: 500, - }; - }); + describe('WebSocket subscription methods', () => { + describe('subscribeToGameUpdates', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToGameUpdates = jest + .fn() + .mockReturnValue(mockUnsubscribe); - controller.clearWithdrawTransaction(); + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + mockCallback, + ); - expect(controller.state.withdrawTransaction).toBeNull(); + expect( + mockPolymarketProvider.subscribeToGameUpdates, + ).toHaveBeenCalledWith('game123', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); }); - }); - it('clear withdraw transaction does not affect other state properties', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-789', - amount: 200, - }; - state.eligibility = { eligible: true, country: 'PT' }; - state.lastError = 'Some error'; - }); + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + delete ( + mockPolymarketProvider as { subscribeToGameUpdates?: unknown } + ).subscribeToGameUpdates; - const originalEligibility = controller.state.eligibility; - const originalLastError = controller.state.lastError; + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + ); - controller.clearWithdrawTransaction(); + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); - expect(controller.state.withdrawTransaction).toBeNull(); - expect(controller.state.eligibility).toEqual(originalEligibility); - expect(controller.state.lastError).toBe(originalLastError); + it('returns no-op function for unknown provider', () => { + withController(({ controller }) => { + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); }); }); - }); - describe('confirmClaim', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + describe('subscribeToMarketPrices', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToMarketPrices = jest + .fn() + .mockReturnValue(mockUnsubscribe); - it('clears claimable positions from state after confirmation', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - createMockPosition({ - id: 'position-2', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }), - ]; + const unsubscribe = controller.subscribeToMarketPrices( + ['token1', 'token2'], + mockCallback, + ); - // Set up state with claimable positions - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = mockPositions; + expect( + mockPolymarketProvider.subscribeToMarketPrices, + ).toHaveBeenCalledWith(['token1', 'token2'], mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + delete ( + mockPolymarketProvider as { subscribeToMarketPrices?: unknown } + ).subscribeToMarketPrices; - // Act - controller.confirmClaim({ address: testAddress }); + const unsubscribe = controller.subscribeToMarketPrices( + ['token1'], + jest.fn(), + ); - // Assert - expect(controller.state.claimablePositions[testAddress]).toEqual([]); + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); }); }); - it('calls provider confirmClaim with correct positions', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - ]; + describe('getConnectionStatus', () => { + it('returns connection status from provider', () => { + withController(({ controller }) => { + mockPolymarketProvider.getConnectionStatus = jest + .fn() + .mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = mockPositions; + const status = controller.getConnectionStatus(); + + expect(mockPolymarketProvider.getConnectionStatus).toHaveBeenCalled(); + expect(status).toEqual({ + sportsConnected: true, + marketConnected: false, + }); }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); + it('returns disconnected status when provider lacks method', () => { + withController(({ controller }) => { + delete (mockPolymarketProvider as { getConnectionStatus?: unknown }) + .getConnectionStatus; - // Act - controller.confirmClaim({ address: testAddress }); + const status = controller.getConnectionStatus(); - // Assert - expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ - positions: mockPositions, - signer: expect.objectContaining({ - address: testAddress, - }), + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); }); }); - }); - it('returns early when no claimable positions exist', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; + it('returns disconnected status for unknown provider', () => { + withController(({ controller }) => { + const status = controller.getConnectionStatus(); - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = []; + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); }); + }); + }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); - - // Act - controller.confirmClaim({ address: testAddress }); + describe('Analytics Tracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - // Assert - expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + it('calls analytics.trackEvent for trackPredictOrderEvent', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'succeeded', + analyticsProperties: { marketId: 'test' }, + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); - it('returns early when claimable positions undefined for address', async () => { - // Arrange + it('does not call analytics.trackEvent when analyticsProperties is missing for trackPredictOrderEvent', async () => { await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.claimablePositions = {}; + await controller.trackPredictOrderEvent({ + status: 'succeeded', }); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); - - // Act - controller.confirmClaim({ - address: '0x1234567890123456789012345678901234567890', + it('includes orderType in analytics properties when provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + orderType: 'FAK', }); - // Assert - expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + order_type: 'FAK', + }), + }), + ); }); }); - it('handles provider without confirmClaim method', async () => { - // Arrange + it('omits orderType from analytics properties when not provided', async () => { await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - ]; - - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = mockPositions; + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, }); - // Remove confirmClaim method from provider - delete (mockPolymarketProvider as { confirmClaim?: unknown }) - .confirmClaim; - - // Act - controller.confirmClaim({ address: testAddress }); - - // Assert - should not throw, state should still be cleared - expect(controller.state.claimablePositions[testAddress]).toEqual([]); + const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; + expect(eventArg.properties).not.toHaveProperty('order_type'); }); }); - }); - describe('getPositions', () => { - beforeEach(() => { - jest.clearAllMocks(); + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { + withController(({ controller }) => { + controller.trackMarketDetailsOpened({ + marketId: 'test', + marketTitle: 'test', + entryPoint: 'test', + marketDetailsViewed: 'test', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); }); - it('defaults to polymarket provider when no providerId specified', async () => { - // Arrange - await withController(async ({ controller }) => { - const mockPositions = [ - { - id: 'position-1', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 100, - cashPnl: 0, - }, - ]; + it('calls analytics.trackEvent for trackPositionViewed', () => { + withController(({ controller }) => { + controller.trackPositionViewed({ openPositionsCount: 5 }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); - mockPolymarketProvider.getPositions = jest - .fn() - .mockResolvedValue(mockPositions); + it('calls analytics.trackEvent for trackActivityViewed', () => { + withController(({ controller }) => { + controller.trackActivityViewed({ activityType: 'all' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); - // Act - const result = await controller.getPositions({ - address: '0x1234567890123456789012345678901234567890', + it('calls analytics.trackEvent for trackGeoBlockTriggered', () => { + withController(({ controller }) => { + controller.trackGeoBlockTriggered({ + attemptedAction: 'deposit', }); - - // Assert - expect(result).toEqual(mockPositions); - expect(mockPolymarketProvider.getPositions).toHaveBeenCalled(); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); - it('stores claimable positions keyed by address', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockClaimablePositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - createMockPosition({ - id: 'position-2', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }), - ]; - - mockPolymarketProvider.getPositions = jest - .fn() - .mockResolvedValue(mockClaimablePositions); - - // Act - await controller.getPositions({ - address: testAddress, - claimable: true, + it('calls analytics.trackEvent for trackFeedViewed', () => { + withController(({ controller }) => { + controller.trackFeedViewed({ + sessionId: 'test', + feedTab: 'test', + numPagesViewed: 1, + sessionTime: 1000, }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); - // Assert - expect(controller.state.claimablePositions[testAddress]).toHaveLength( - 2, - ); - expect(controller.state.claimablePositions[testAddress]).toEqual( - mockClaimablePositions, - ); + it('calls analytics.trackEvent for trackShareAction', () => { + withController(({ controller }) => { + controller.trackShareAction({ status: 'success' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); }); - describe('invalidateQueryCache', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); + describe('onPlaceOrderEnd', () => { + it('clears activeOrder and selectedPaymentToken', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.SUCCESS, + }); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); - afterEach(() => { - jest.useRealTimers(); + controller.onPlaceOrderEnd(); + + expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.selectedPaymentToken).toBeNull(); + }); }); - it('calls NetworkController.findNetworkClientIdByChainId with hex chain ID', async () => { - const mockFindNetworkClientIdByChainId = jest - .fn() - .mockReturnValue('polygon-mainnet'); - await withController( - async ({ controller }) => { - // Arrange - const chainId = 137; + it('does not clear pendingOrderPreviews on navigation away', () => { + withController(({ controller }) => { + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + }; + }; + } + ).pendingOrderPreviews['tx-123'] = { + preview: createMockOrderPreview(), + signerAddress: MOCK_ADDRESS, + }; - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + controller.onPlaceOrderEnd(); - // Assert - expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0x89'); - }, - { - mocks: { - findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, - }, - }, - ); + expect( + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + }; + }; + } + ).pendingOrderPreviews['tx-123'], + ).toBeDefined(); + }); }); + }); - it('calls NetworkController.getNetworkClientById with network client ID', async () => { - const mockGetNetworkClientById = jest - .fn() - .mockReturnValue(DEFAULT_NETWORK_CLIENT); + describe('placeOrder with activeOrder', () => { + it('transitions pay with any token orders to depositing', async () => { await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + const preview = createMockOrderPreview({ side: Side.BUY }); - // Assert - expect(mockGetNetworkClientById).toHaveBeenCalledWith( - 'polygon-mainnet', - ); + const result = await controller.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview, + transactionId: 'tx-deposit-1', + }); + + expect(result).toEqual({ + success: false, + response: { status: 'deposit_in_progress' }, + }); + expect(controller.state.activeBuyOrder).toEqual({ + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-deposit-1', + }); + expect(mockPolymarketProvider.placeOrder).not.toHaveBeenCalled(); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('calls blockTracker.checkForLatestBlock to invalidate cache', async () => { - const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, + it('sets activeOrder to success after provider order placement succeeds', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', }, - }); - + }; await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + const preview = createMockOrderPreview({ side: Side.BUY }); - // Assert - expect(mockCheckForLatestBlock).toHaveBeenCalledWith(); + await controller.placeOrder({ preview }); + + expect(controller.state.activeBuyOrder).toEqual({ + state: ActiveOrderState.SUCCESS, + }); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('logs error when blockTracker.checkForLatestBlock fails', async () => { - const mockError = new Error('Block tracker error'); - const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, - }, - }); - + it('retries pay-with-any-token init after order placement fails with an active transactionId', async () => { await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: true, + response: { + batchId: 'batch-2', + }, + } as never); - // Assert - expect(DevLogger.log).toHaveBeenCalledWith( - 'PredictController: Error invalidating query cache', - expect.objectContaining({ - error: 'Block tracker error', - timestamp: expect.any(String), - }), + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); + + expect(retrySpy).toHaveBeenCalledTimes(1); + expect( + controller.state.activeBuyOrder?.transactionId, + ).toBeUndefined(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.activeBuyOrder?.error).toBe( + 'Order placement failed', ); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('continues execution when invalidation fails', async () => { - const mockError = new Error('Block tracker error'); - const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, - }, - }); + it('passes explicit address to provider.placeOrder signer', async () => { + const explicitAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - // Act & Assert - should not throw - await expect( - // eslint-disable-next-line dot-notation - controller['invalidateQueryCache'](chainId), - ).resolves.not.toThrow(); + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-explicit', + spentAmount: '50', + receivedAmount: '100', + }, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview, address: explicitAddress }); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.SUCCESS, + ); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + signer: expect.objectContaining({ + address: explicitAddress, + }), + }), + ); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - }); - - describe('placeOrder - optimistic balance updates', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('decreases balance by spent amount for BUY orders', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '100.50', - receivedAmount: '200', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + it('retries initPayWithAnyToken after order placement fails', async () => { await withController( async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-explicit', }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(1000 - 100.5); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: true, + response: { batchId: 'batch-retry' }, + } as never); + + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order failed', + ); + + expect(retrySpy).toHaveBeenCalledTimes(1); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('sets validUntil to 5 seconds in future for BUY orders', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '50', - receivedAmount: '100', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - const now = Date.now(); - jest.setSystemTime(now); - + it('does not update activeBuyOrder when background order completes for a different active order', async () => { await withController( async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.validUntil).toBe(now + 5000); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance(), + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'] = { + preview: createMockOrderPreview({ side: Side.BUY }), + signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-bg-1', + spentAmount: '100', + receivedAmount: '200', }, - }, - }, - ); - }); + }); - it('increases balance by received amount for SELL orders', async () => { - const preview = createMockOrderPreview({ side: Side.SELL }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '100', - receivedAmount: '95.50', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const preview = createMockOrderPreview({ side: Side.BUY }); - await withController( - async ({ controller }) => { - // Act await controller.placeOrder({ preview, + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionId: 'tx-bg-1', }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(500 + 95.5); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); + expect( + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'], + ).toBeUndefined(); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 500 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('sets validUntil to 5 seconds in future for SELL orders', async () => { - const preview = createMockOrderPreview({ side: Side.SELL }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '100', - receivedAmount: '50', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - const now = Date.now(); - jest.setSystemTime(now); - + it('does not clear selectedPaymentToken when background order fails for a different active order', async () => { await withController( async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.validUntil).toBe(now + 5000); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); + + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect( + controller.placeOrder({ preview, transactionId: 'tx-bg-1' }), + ).rejects.toThrow('Order failed'); + + expect(controller.state.selectedPaymentToken).toEqual({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance(), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('results in NaN balance when parsing invalid spentAmount', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: 'invalid', - receivedAmount: '100', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - + it('publishes confirmed event when background order succeeds for a different active order', async () => { await withController( - async ({ controller }) => { - // Act + async ({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'] = { + preview: createMockOrderPreview({ side: Side.BUY }), + signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-bg-1', + spentAmount: '100', + receivedAmount: '200', + }, + }); + + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + await controller.placeOrder({ preview, + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionId: 'tx-bg-1', }); - // Assert - parseFloat('invalid') returns NaN - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBeNaN(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'confirmed', + }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('does not update balance when provider.placeOrder fails', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - mockPolymarketProvider.placeOrder.mockRejectedValue( - new Error('Order failed'), - ); - + it('publishes failed event when background order fails for a different active order', async () => { await withController( - async ({ controller }) => { - // Act + async ({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + await expect( controller.placeOrder({ preview, + transactionId: 'tx-bg-1', }), ).rejects.toThrow('Order failed'); - // Assert - balance should remain unchanged - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(1000); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('results in NaN balance when spentAmount is empty for BUY orders', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '', - receivedAmount: '100', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - + it('does not enter PAY_WITH_ANY_TOKEN branch when transactionId has existing pending preview', async () => { await withController( async ({ controller }) => { - // Act + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); + + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'] = { + preview: createMockOrderPreview({ side: Side.BUY }), + signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-bg-1', + spentAmount: '100', + receivedAmount: '200', + }, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + await controller.placeOrder({ preview, + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionId: 'tx-bg-1', }); - // Assert - parseFloat('') returns NaN, so balance becomes NaN - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBeNaN(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); + }); - it('results in NaN balance when receivedAmount is empty for SELL orders', async () => { - const preview = createMockOrderPreview({ side: Side.SELL }); + describe('placeOrder with predictWithAnyToken flag disabled', () => { + it('skips deposit flow and places order directly when activeOrder is PAY_WITH_ANY_TOKEN', async () => { const mockResult = { success: true as const, response: { - id: 'order-123', + id: 'order-direct', spentAmount: '100', - receivedAmount: '', + receivedAmount: '200', }, }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - await withController( - async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, - }); + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); - // Assert - parseFloat('') returns NaN, so balance becomes NaN - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBeNaN(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 500 }), - }, - }, - }, - ); + const preview = createMockOrderPreview({ side: Side.BUY }); + + const result = await controller.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview, + }); + + expect(result.success).toBe(true); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).not.toBe( + ActiveOrderState.DEPOSITING, + ); + }); }); - }); - describe('getBalance - caching behavior', () => { - beforeEach(() => { - jest.useFakeTimers(); + it('does not update activeOrder to PLACING_ORDER for buy orders', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview }); + + expect(controller.state.activeBuyOrder).toBeNull(); + }); }); - afterEach(() => { - jest.useRealTimers(); + it('does not update activeOrder to SUCCESS after successful order', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview }); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }); }); - it('returns cached balance when validUntil is in future', async () => { - const now = Date.now(); - jest.setSystemTime(now); + it('does not update activeOrder to PREVIEW on error', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PLACING_ORDER, + }); - await withController( - async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + const preview = createMockOrderPreview({ side: Side.BUY }); - // Assert - expect(result).toBe(1500); - expect(mockPolymarketProvider.getBalance).not.toHaveBeenCalled(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now + 500, - }), - }, - }, - }, - ); + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order failed', + ); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PLACING_ORDER, + ); + expect(controller.state.lastError).toBe('Order failed'); + }); }); - it('fetches fresh balance when cache expired', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(2000); + it('does not retry initPayWithAnyToken on error with active batch', async () => { + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'batch-1', + }); - await withController( - async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: true, + response: { batchId: 'batch-2' }, + } as never); - // Assert - expect(result).toBe(2000); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order failed', + ); + + expect(retrySpy).not.toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.transactionId).toBe('batch-1'); + }); + }); + }); + + describe('handleTransactionSideEffects for depositAndOrder', () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + + const createPredictTransactionMeta = ({ + nestedType, + status, + batchId, + }: { + nestedType: TransactionType; + status: TransactionStatus; + batchId?: string; + }) => + ({ + id: 'tx-1', + status, + batchId, + txParams: { + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now - 100, - }), + nestedTransactions: [ + { + type: nestedType, + }, + ], + }) as any; + + it('places the order when depositAndOrder transaction is confirmed and preview exists', () => { + withController(({ controller, messenger }) => { + const preview = createMockOrderPreview(); + const placeOrderSpy = jest + .spyOn(controller, 'placeOrder') + .mockResolvedValue({ + success: true, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', }, + } as any); + + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-1'] = { + preview, + signerAddress: accountAddress, + analyticsProperties: { marketId: 'market-1' }, + }; + + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(placeOrderSpy).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-1' }, + preview, + address: accountAddress, + transactionId: 'tx-1', + }); + }); }); - it('fetches fresh balance when no cached balance exists', async () => { - mockPolymarketProvider.getBalance.mockResolvedValue(1000); + it('does not place order when depositAndOrder confirmed without stored pendingOrderPreview', () => { + withController(({ controller, messenger }) => { + const placeOrderSpy = jest.spyOn(controller, 'placeOrder'); - await withController(async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - // Assert - expect(result).toBe(1000); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + }, + } as { transactionMeta: TransactionMeta }); + + expect(placeOrderSpy).not.toHaveBeenCalled(); }); }); - it('updates cache with validUntil 1 second in future after fetch', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(2500); + it('returns activeBuyOrder to preview and retries when depositAndOrder transaction fails', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - await withController(async ({ controller }) => { - // Act - await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(2500); - expect(updatedBalance.validUntil).toBe(now + 1000); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(retrySpy).toHaveBeenCalledTimes(1); }); }); - it('calls invalidateQueryCache before fetching fresh balance', async () => { - const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, - }, - }); - mockPolymarketProvider.getBalance.mockResolvedValue(1000); + it('retries when depositAndOrder transaction fails, even if the error message indicates user rejection', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); - await withController( - async ({ controller }) => { - // Act - await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: false, + error: 'User rejected the request.', + } as never); - // Assert - expect(mockCheckForLatestBlock).toHaveBeenCalled(); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); - }, - { - mocks: { - findNetworkClientIdByChainId: jest - .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'User rejected the request.', code: 4001 }, }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(retrySpy).toHaveBeenCalledTimes(1); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.activeBuyOrder?.error).toBe( + 'User rejected the request.', + ); + }); }); - it('fetches balance when validUntil equals current time', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(1800); + it('uses default error message when depositAndOrder fails without error message', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - await withController( - async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - // Assert - expect(result).toBe(1800); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now, - }), - }, + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.activeBuyOrder?.error).toBeDefined(); + expect(retrySpy).toHaveBeenCalledTimes(1); + }); }); - it('caches balance per providerId and address combination', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(3000); + it('publishes order failed event when depositAndOrder fails and there is no active buy order', () => { + withController(({ controller, messenger }) => { + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - await withController( - async ({ controller }) => { - // Act - fetch for different address - const result = await controller.getBalance({ - address: '0xdifferentaddress000000000000000000000000', - }); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); - // Assert - expect(result).toBe(3000); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); - // Original cached balance should still exist - expect( - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ].balance, - ).toBe(1500); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now + 500, - }), - }, + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); + }); }); - }); - describe('WebSocket subscription methods', () => { - describe('subscribeToGameUpdates', () => { - it('delegates to provider and returns unsubscribe function', () => { - withController(({ controller }) => { - const mockUnsubscribe = jest.fn(); - const mockCallback = jest.fn(); - mockPolymarketProvider.subscribeToGameUpdates = jest - .fn() - .mockReturnValue(mockUnsubscribe); + it('does not publish order failed event when depositAndOrder fails and there is an active buy order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - const unsubscribe = controller.subscribeToGameUpdates( - 'game123', - mockCallback, - ); + jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - expect( - mockPolymarketProvider.subscribeToGameUpdates, - ).toHaveBeenCalledWith('game123', mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); }); + }); - it('returns no-op function when provider lacks method', () => { - withController(({ controller }) => { - delete ( - mockPolymarketProvider as { subscribeToGameUpdates?: unknown } - ).subscribeToGameUpdates; + it('does not update activeBuyOrder when deposit confirms for a different active order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - const unsubscribe = controller.subscribeToGameUpdates( - 'game123', - jest.fn(), - ); + const preview = createMockOrderPreview(); + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-1'] = { + preview, + signerAddress: accountAddress, + analyticsProperties: { marketId: 'market-1' }, + }; - expect(unsubscribe).toBeDefined(); - expect(unsubscribe()).toBeUndefined(); + const placeOrderSpy = jest + .spyOn(controller, 'placeOrder') + .mockResolvedValue({ + success: true, + response: { + id: 'order-bg', + spentAmount: '100', + receivedAmount: '200', + }, + } as any); + + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, }); - }); - it('returns no-op function for unknown provider', () => { - withController(({ controller }) => { - const unsubscribe = controller.subscribeToGameUpdates( - 'game123', - jest.fn(), - ); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + }, + } as { transactionMeta: TransactionMeta }); - expect(unsubscribe).toBeDefined(); - expect(unsubscribe()).toBeUndefined(); + expect(placeOrderSpy).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-1' }, + preview, + address: accountAddress, + transactionId: 'tx-1', }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); }); }); - describe('subscribeToMarketPrices', () => { - it('delegates to provider and returns unsubscribe function', () => { - withController(({ controller }) => { - const mockUnsubscribe = jest.fn(); - const mockCallback = jest.fn(); - mockPolymarketProvider.subscribeToMarketPrices = jest - .fn() - .mockReturnValue(mockUnsubscribe); + it('does not retry initPayWithAnyToken when deposit fails for a different active order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - const unsubscribe = controller.subscribeToMarketPrices( - ['token1', 'token2'], - mockCallback, - ); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - expect( - mockPolymarketProvider.subscribeToMarketPrices, - ).toHaveBeenCalledWith(['token1', 'token2'], mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, }); - }); - - it('returns no-op function when provider lacks method', () => { - withController(({ controller }) => { - delete ( - mockPolymarketProvider as { subscribeToMarketPrices?: unknown } - ).subscribeToMarketPrices; - const unsubscribe = controller.subscribeToMarketPrices( - ['token1'], - jest.fn(), - ); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); - expect(unsubscribe).toBeDefined(); - expect(unsubscribe()).toBeUndefined(); - }); + expect(retrySpy).not.toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); }); }); - describe('getConnectionStatus', () => { - it('returns connection status from provider', () => { - withController(({ controller }) => { - mockPolymarketProvider.getConnectionStatus = jest - .fn() - .mockReturnValue({ - sportsConnected: true, - marketConnected: false, - }); + it('publishes failed event when deposit fails for a different active order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - const status = controller.getConnectionStatus(); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - expect(mockPolymarketProvider.getConnectionStatus).toHaveBeenCalled(); - expect(status).toEqual({ - sportsConnected: true, - marketConnected: false, - }); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); }); + }); - it('returns disconnected status when provider lacks method', () => { - withController(({ controller }) => { - delete (mockPolymarketProvider as { getConnectionStatus?: unknown }) - .getConnectionStatus; + describe('when user switched accounts after initiating deposit', () => { + const originalAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const currentlySelectedAddress = MOCK_ADDRESS; + const createSwitchedAccountTransactionMeta = ({ + nestedType, + status, + batchId, + }: { + nestedType: TransactionType; + status: TransactionStatus; + batchId?: string; + }) => + ({ + id: 'tx-switched', + status, + batchId, + txParams: { + from: originalAddress, + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', + }, + nestedTransactions: [ + { + type: nestedType, + }, + ], + }) as any; + + it('forwards the transaction address to placeOrder when depositAndOrder confirms after account switch', () => { + withController(({ controller, messenger }) => { + const preview = createMockOrderPreview(); + const placeOrderSpy = jest + .spyOn(controller, 'placeOrder') + .mockResolvedValue({ + success: true, + response: { + id: 'order-456', + spentAmount: '100', + receivedAmount: '200', + }, + } as any); - const status = controller.getConnectionStatus(); + controller.updateStateForTesting((state) => { + state.activeBuyOrder = { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-switched', + }; + }); + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-switched'] = { + preview, + signerAddress: originalAddress, + analyticsProperties: { marketId: 'market-2' }, + }; - expect(status).toEqual({ - sportsConnected: false, - marketConnected: false, + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...createSwitchedAccountTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }), + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + }, + } as { transactionMeta: TransactionMeta }); + + expect(placeOrderSpy).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-2' }, + preview, + address: originalAddress, + transactionId: 'tx-switched', }); }); }); - it('returns disconnected status for unknown provider', () => { - withController(({ controller }) => { - const status = controller.getConnectionStatus(); - - expect(status).toEqual({ - sportsConnected: false, - marketConnected: false, + it('forwards the transaction address to initPayWithAnyToken when depositAndOrder fails after account switch', () => { + withController(({ controller, messenger }) => { + controller.updateStateForTesting((state) => { + state.activeBuyOrder = { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-switched', + }; }); + + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...createSwitchedAccountTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }), + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(retrySpy).toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); }); }); }); }); - describe('Analytics Tracking', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('calls analytics.trackEvent for trackPredictOrderEvent', async () => { + describe('initPayWithAnyToken error branches', () => { + it('returns a failed result when deposit preparation returns undefined', async () => { await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'succeeded', - analyticsProperties: { marketId: 'test' }, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); - it('does not call analytics.trackEvent when analyticsProperties is missing for trackPredictOrderEvent', async () => { - await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'succeeded', + mockPolymarketProvider.prepareDeposit.mockResolvedValue( + undefined as never, + ); + + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Deposit preparation returned undefined', }); - expect(analytics.trackEvent).not.toHaveBeenCalled(); }); }); - it('includes orderType in analytics properties when provided', async () => { + it('returns a failed result when deposit preparation returns empty transactions', async () => { await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'submitted', - analyticsProperties: { marketId: 'test' }, - orderType: 'FAK', + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - expect(analytics.trackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - properties: expect.objectContaining({ - order_type: 'FAK', - }), - }), - ); + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [], + chainId: '0x89', + }); + + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'No transactions returned from deposit preparation', + }); }); }); - it('omits orderType from analytics properties when not provided', async () => { + it('returns a failed result when deposit preparation returns no chainId', async () => { await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'submitted', - analyticsProperties: { marketId: 'test' }, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; - expect(eventArg.properties).not.toHaveProperty('order_type'); - }); - }); + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [ + { + params: { + to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, + data: '0xa9059cbb' as `0x${string}`, + }, + type: TransactionType.predictDeposit, + }, + ], + } as never); - it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { - withController(({ controller }) => { - controller.trackMarketDetailsOpened({ - marketId: 'test', - marketTitle: 'test', - entryPoint: 'test', - marketDetailsViewed: 'test', + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Chain ID not provided by deposit preparation', }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); - it('calls analytics.trackEvent for trackPositionViewed', () => { - withController(({ controller }) => { - controller.trackPositionViewed({ openPositionsCount: 5 }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); + it('returns a failed result when network client is not found for chain ID', async () => { + await withController( + async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - it('calls analytics.trackEvent for trackActivityViewed', () => { - withController(({ controller }) => { - controller.trackActivityViewed({ activityType: 'all' }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Network client not found for chain ID: 0x89', + }); + }, + { + mocks: { + findNetworkClientIdByChainId: jest.fn().mockReturnValue(undefined), + }, + }, + ); }); - it('calls analytics.trackEvent for trackGeoBlockTriggered', () => { - withController(({ controller }) => { - controller.trackGeoBlockTriggered({ - attemptedAction: 'deposit', + it('returns a failed result when transaction batch returns no batchId', async () => { + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); - it('calls analytics.trackEvent for trackFeedViewed', () => { - withController(({ controller }) => { - controller.trackFeedViewed({ - sessionId: 'test', - feedTab: 'test', - numPagesViewed: 1, - sessionTime: 1000, - }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); + (addTransactionBatch as jest.Mock).mockResolvedValue({}); - it('calls analytics.trackEvent for trackShareAction', () => { - withController(({ controller }) => { - controller.trackShareAction({ status: 'success' }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Failed to get batch ID from transaction submission', + }); }); }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 2ef87af077a..d13d6a0dbc1 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -1,64 +1,82 @@ -import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction } from '@metamask/account-tree-controller'; -import { isEvmAccountType } from '@metamask/keyring-api'; +import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { BaseController, ControllerGetStateAction, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; -import type { Messenger } from '@metamask/messenger'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { isEvmAccountType } from '@metamask/keyring-api'; import { + KeyringControllerSignPersonalMessageAction, + KeyringControllerSignTypedMessageAction, PersonalMessageParams, SignTypedDataVersion, TypedMessageParams, - KeyringControllerSignTypedMessageAction, - KeyringControllerSignPersonalMessageAction, } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; import { - NetworkControllerGetStateAction, NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, } from '@metamask/network-controller'; import { - TransactionControllerTransactionStatusUpdatedEvent, + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; +import { TransactionControllerEstimateGasAction, TransactionControllerTransactionConfirmedEvent, TransactionControllerTransactionFailedEvent, TransactionControllerTransactionRejectedEvent, + TransactionControllerTransactionStatusUpdatedEvent, TransactionControllerTransactionSubmittedEvent, TransactionMeta, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; -import { - RemoteFeatureFlagControllerGetStateAction, - RemoteFeatureFlagControllerStateChangeEvent, -} from '@metamask/remote-feature-flag-controller'; import { Hex, hexToNumber, numberToHex } from '@metamask/utils'; import performance from 'react-native-performance'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; -import { analytics } from '../../../../util/analytics/analytics'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../util/Logger'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { analytics } from '../../../../util/analytics/analytics'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; import { - trace, endTrace, + trace, TraceName, TraceOperation, } from '../../../../util/trace'; import { addTransactionBatch } from '../../../../util/transaction-controller'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; import { PredictEventProperties, PredictShareStatusValue, PredictTradeStatus, PredictTradeStatusValue, } from '../constants/eventNames'; -import { validateDepositTransactions } from '../utils/validateTransactions'; +import { + DEFAULT_FEE_COLLECTION_FLAG, + DEFAULT_LIVE_SPORTS_FLAG, + DEFAULT_MARKET_HIGHLIGHTS_FLAG, +} from '../constants/flags'; +import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; +import { filterSupportedLeagues } from '../constants/sports'; +import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; +import { + MATIC_CONTRACTS, + POLYMARKET_PROVIDER_ID, +} from '../providers/polymarket/constants'; import { Signer } from '../providers/types'; +import { parse, PredictFeeCollectionSchema } from '../schemas'; import { AccountState, ActiveOrderState, @@ -94,31 +112,14 @@ import { Side, UnrealizedPnL, } from '../types'; -import { ensureError } from '../utils/predictErrorHandler'; -import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; -import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; -import { - MATIC_CONTRACTS, - POLYMARKET_PROVIDER_ID, -} from '../providers/polymarket/constants'; -import { - DEFAULT_FEE_COLLECTION_FLAG, - DEFAULT_LIVE_SPORTS_FLAG, - DEFAULT_MARKET_HIGHLIGHTS_FLAG, -} from '../constants/flags'; -import { filterSupportedLeagues } from '../constants/sports'; import { PredictFeatureFlags, PredictLiveSportsFlag, PredictMarketHighlightsFlag, } from '../types/flags'; -import { - VersionGatedFeatureFlag, - validatedVersionGatedFeatureFlag, -} from '../../../../util/remoteFeatureFlag'; import { unwrapRemoteFeatureFlag } from '../utils/flags'; -import { parse, PredictFeeCollectionSchema } from '../schemas'; -import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../constants/transactions'; +import { ensureError } from '../utils/predictErrorHandler'; +import { validateDepositTransactions } from '../utils/validateTransactions'; /** * State shape for PredictController @@ -150,10 +151,8 @@ export type PredictControllerState = { // TODO: change to be per-account basis withdrawTransaction: PredictWithdraw | null; - activeOrder?: { - amount?: number; - batchId?: string; - isInputFocused?: boolean; + activeBuyOrder: { + transactionId?: string; state: ActiveOrderState; error?: string; } | null; @@ -182,7 +181,7 @@ export const getDefaultPredictControllerState = (): PredictControllerState => ({ pendingDeposits: {}, pendingClaims: {}, withdrawTransaction: null, - activeOrder: null, + activeBuyOrder: null, selectedPaymentToken: null, accountMeta: {}, }); @@ -245,7 +244,7 @@ const metadata: StateMetadata = { includeInStateLogs: false, usedInUi: true, }, - activeOrder: { + activeBuyOrder: { persist: false, includeInDebugSnapshot: false, includeInStateLogs: false, @@ -262,7 +261,12 @@ const metadata: StateMetadata = { /** * PredictController events */ -export type PredictTransactionEventType = 'deposit' | 'claim' | 'withdraw'; +export type PredictTransactionEventType = + | 'deposit' + | 'depositAndOrder' + | 'claim' + | 'withdraw' + | 'order'; export type PredictTransactionEventStatus = | 'approved' @@ -279,6 +283,7 @@ export interface PredictControllerTransactionStatusChangedEvent { senderAddress: string; transactionId?: string; amount?: number; + marketId?: string; }, ]; } @@ -361,6 +366,14 @@ export class PredictController extends BaseController< > { private provider: PolymarketProvider; + private pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; + }; + } = {}; + constructor({ messenger, state = {} }: PredictControllerOptions) { super({ name: 'PredictController', @@ -458,7 +471,10 @@ export class PredictController extends BaseController< const remoteFeatureFlagState = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - const flags = remoteFeatureFlagState.remoteFeatureFlags; + const flags = { + ...(remoteFeatureFlagState.remoteFeatureFlags ?? {}), + ...(remoteFeatureFlagState.localOverrides ?? {}), + }; const liveSportsFlag = unwrapRemoteFeatureFlag(flags.predictLiveSports) ?? @@ -494,14 +510,29 @@ export class PredictController extends BaseController< ), ) ?? false; + const predictWithAnyTokenEnabled = + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + flags.predictWithAnyToken, + ), + ) ?? false; + return { feeCollection, liveSportsLeagues, marketHighlightsFlag, fakOrdersEnabled, + predictWithAnyTokenEnabled, }; } + private isCurrentActiveBuyOrder(transactionId?: string): boolean { + if (!this.state.activeBuyOrder) return false; + if (!transactionId) return true; + if (!this.state.activeBuyOrder.transactionId) return false; + return this.state.activeBuyOrder.transactionId === transactionId; + } + private getEvmAccountAddress(): string { const accounts = this.messenger.call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', @@ -1444,6 +1475,54 @@ export class PredictController extends BaseController< } async placeOrder(params: PlaceOrderParams): Promise { + const activeOrderAddress = params.address ?? this.getEvmAccountAddress(); + const { predictWithAnyTokenEnabled } = this.resolveFeatureFlags(); + const canUpdateActiveBuyOrder = this.isCurrentActiveBuyOrder( + params.transactionId, + ); + + const isExistingPendingOrder = + !!params.transactionId && + !!this.pendingOrderPreviews[params.transactionId]; + + if ( + predictWithAnyTokenEnabled && + this.state.activeBuyOrder?.state === + ActiveOrderState.PAY_WITH_ANY_TOKEN && + !isExistingPendingOrder + ) { + const transactionId = params.transactionId; + if (transactionId) { + this.pendingOrderPreviews[transactionId] = { + preview: params.preview, + signerAddress: activeOrderAddress, + analyticsProperties: params.analyticsProperties, + }; + } + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.DEPOSITING; + state.activeBuyOrder.transactionId = transactionId; + } + }); + return { + success: false, + response: { status: 'deposit_in_progress' }, + } as unknown as Result; + } + + if ( + predictWithAnyTokenEnabled && + params.preview.side === Side.BUY && + canUpdateActiveBuyOrder + ) { + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PLACING_ORDER; + } + }); + } + const startTime = performance.now(); const { analyticsProperties, preview } = params; @@ -1478,7 +1557,10 @@ export class PredictController extends BaseController< try { const provider = this.provider; - const signer = this.getSigner(); + const signer = this.getSigner(activeOrderAddress); + + //await new Promise((resolve) => setTimeout(resolve, 1000)); + //throw new Error('Test error'); // Track Predict Trade Transaction with submitted status (fire and forget) this.trackPredictOrderEvent({ @@ -1504,6 +1586,18 @@ export class PredictController extends BaseController< throw new Error(result.error); } + if ( + predictWithAnyTokenEnabled && + preview.side === Side.BUY && + canUpdateActiveBuyOrder + ) { + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.SUCCESS; + } + }); + } + const { spentAmount, receivedAmount } = result.response; const cachedBalance = this.state.balances[signer.address]?.balance ?? 0; @@ -1540,6 +1634,19 @@ export class PredictController extends BaseController< // If we can't get real share price, continue without it } + if ( + predictWithAnyTokenEnabled && + preview.side === Side.BUY && + !canUpdateActiveBuyOrder + ) { + this.messenger.publish('PredictController:transactionStatusChanged', { + type: 'order', + status: 'confirmed', + senderAddress: signer.address, + marketId: analyticsProperties?.marketId, + }); + } + // Track Predict Trade Transaction with succeeded status (fire and forget) this.trackPredictOrderEvent({ status: PredictTradeStatus.SUCCEEDED, @@ -1574,10 +1681,31 @@ export class PredictController extends BaseController< this.update((state) => { state.lastError = errorMessage; state.lastUpdateTimestamp = Date.now(); + if ( + predictWithAnyTokenEnabled && + preview.side === Side.BUY && + canUpdateActiveBuyOrder && + state.activeBuyOrder + ) { + state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + state.activeBuyOrder.error = errorMessage; + } + if (canUpdateActiveBuyOrder) { + state.selectedPaymentToken = null; + } }); traceData = { success: false, error: errorMessage }; + if (!canUpdateActiveBuyOrder) { + this.messenger.publish('PredictController:transactionStatusChanged', { + type: 'order', + status: 'failed', + senderAddress: activeOrderAddress, + marketId: analyticsProperties?.marketId, + }); + } + // Log to Sentry with order context (excluding sensitive data like amounts) Logger.error( ensureError(error), @@ -1591,6 +1719,26 @@ export class PredictController extends BaseController< }), ); + if ( + predictWithAnyTokenEnabled && + canUpdateActiveBuyOrder && + this.state.activeBuyOrder?.transactionId + ) { + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.transactionId = undefined; + } + }); + this.initPayWithAnyToken().catch((err) => { + Logger.error( + ensureError(err), + this.getErrorContext('placeOrder', { + operation: 'initPayWithAnyToken', + }), + ); + }); + } + // Log error for debugging and future Sentry integration DevLogger.log('PredictController: Place order failed', { error: errorMessage, @@ -1602,6 +1750,12 @@ export class PredictController extends BaseController< throw new Error(errorMessage); } finally { + if ( + params.transactionId && + this.pendingOrderPreviews[params.transactionId] + ) { + delete this.pendingOrderPreviews[params.transactionId]; + } endTrace({ name: TraceName.PredictPlaceOrder, id: traceId, @@ -1927,15 +2081,73 @@ export class PredictController extends BaseController< this.update(updater); } - public setActiveOrder(order: PredictControllerState['activeOrder']): void { + public clearOrderError(): void { this.update((state) => { - state.activeOrder = order; + if (state.activeBuyOrder) { + delete state.activeBuyOrder.error; + } }); } + public onPlaceOrderEnd(): void { + this.update((state) => { + state.activeBuyOrder = null; + }); + this.setSelectedPaymentToken(null); + } + + public selectPaymentToken(token: AssetType | null): void { + if (!token) { + return; + } + + const isBalanceToken = + token.address === PREDICT_BALANCE_PLACEHOLDER_ADDRESS; + + this.setSelectedPaymentToken( + isBalanceToken + ? null + : { + address: token.address, + chainId: token.chainId ?? '', + symbol: token.symbol, + }, + ); + + const activeOrder = this.state.activeBuyOrder; + if (!activeOrder) { + return; + } + + this.clearOrderError(); + + if (activeOrder.state === ActiveOrderState.PAY_WITH_ANY_TOKEN) { + if (!isBalanceToken) { + return; + } + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + } + }); + return; + } + + if (activeOrder.state === ActiveOrderState.PREVIEW) { + if (isBalanceToken) { + return; + } + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PAY_WITH_ANY_TOKEN; + } + }); + } + } + public clearActiveOrder(): void { this.update((state) => { - state.activeOrder = null; + state.activeBuyOrder = null; }); } @@ -2077,22 +2289,28 @@ export class PredictController extends BaseController< * type so the confirmation routing in `info-root.tsx` renders * `PredictPayWithAnyTokenInfo`. * - * TODO: Remove the cast once `predictDepositAndOrder` is added to - * `@metamask/transaction-controller`. */ - public async payWithAnyTokenConfirmation(): Promise< - Result<{ batchId: string }> - > { + public async initPayWithAnyToken(): Promise> { const provider = this.provider; - try { - const signer = this.getSigner(); - + if (!this.state.activeBuyOrder) { this.update((state) => { - if (state.activeOrder) { - delete state.activeOrder.batchId; - } + state.selectedPaymentToken = null; + state.activeBuyOrder = { + state: ActiveOrderState.PREVIEW, + }; }); + } + + const activeOrder = this.state.activeBuyOrder; + if (!activeOrder) { + throw new Error( + 'Active order is required for pay-with-any-token confirmation', + ); + } + + try { + const signer = this.getSigner(); const depositPreparation = await provider.prepareDeposit({ signer, @@ -2112,17 +2330,13 @@ export class PredictController extends BaseController< throw new Error('Chain ID not provided by deposit preparation'); } - // TODO: Remove cast once predictDepositAndOrder is in @metamask/transaction-controller - const predictDepositAndOrderType = - 'predictDepositAndOrder' as unknown as TransactionType; - // Override transaction types to predictDepositAndOrder so the // confirmation routing renders the deposit-and-order info component. const depositAndOrderTransactions = transactions.map((tx) => ({ ...tx, type: tx.type === TransactionType.predictDeposit - ? predictDepositAndOrderType + ? TransactionType.predictDepositAndOrder : tx.type, })); @@ -2169,9 +2383,8 @@ export class PredictController extends BaseController< const { batchId } = batchResult; this.update((state) => { - if (state.activeOrder) { - state.activeOrder.batchId = batchId; - delete state.activeOrder.error; + if (state.activeBuyOrder) { + delete state.activeBuyOrder.error; } }); @@ -2183,35 +2396,17 @@ export class PredictController extends BaseController< }; } catch (error) { const e = ensureError(error); - if (e.message.includes('User denied transaction signature')) { - this.update((state) => { - if (state.activeOrder) { - state.activeOrder = null; - } - }); - return { - success: true, - response: { batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID }, - }; - } - - const errorMessage = e.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED; - - this.update((state) => { - if (state.activeOrder) { - state.activeOrder.error = errorMessage; - state.activeOrder.batchId = PREDICTION_ERROR_TRANSACTION_BATCH_ID; - } - }); - Logger.error( e, - this.getErrorContext('payWithAnyTokenConfirmation', { + this.getErrorContext('initPayWithAnyToken', { providerId: POLYMARKET_PROVIDER_ID, }), ); - throw new Error(errorMessage); + return { + success: false, + error: e.message, + }; } } @@ -2258,6 +2453,7 @@ export class PredictController extends BaseController< const nestedTransactionType = transactionMeta?.nestedTransactions?.find( ({ type }) => type === TransactionType.predictDeposit || + type === TransactionType.predictDepositAndOrder || type === TransactionType.predictClaim || type === TransactionType.predictWithdraw, )?.type; @@ -2296,7 +2492,7 @@ export class PredictController extends BaseController< }); try { - this.handleTransactionSideEffects(type, status, address); + this.handleTransactionSideEffects(type, status, address, transactionMeta); } catch (error) { Logger.error( ensureError(error), @@ -2323,6 +2519,7 @@ export class PredictController extends BaseController< type: PredictTransactionEventType, status: PredictTransactionEventStatus, address: string, + transactionMeta: TransactionMeta, ): void { const isTerminal = status === 'confirmed' || status === 'failed' || status === 'rejected'; @@ -2331,6 +2528,92 @@ export class PredictController extends BaseController< this.clearPendingDepositForAddress({ address }); } + if (type === 'depositAndOrder' && status === 'confirmed') { + const transactionId = transactionMeta.id; + const pendingOrder = transactionId + ? this.pendingOrderPreviews[transactionId] + : null; + + if (!pendingOrder) { + return; + } + + const { + preview, + signerAddress, + analyticsProperties: pendingAnalytics, + } = pendingOrder; + + this.placeOrder({ + analyticsProperties: pendingAnalytics, + preview, + address: signerAddress, + transactionId, + }).catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('handleTransactionSideEffects', { + operation: 'placeOrder', + }), + ); + }); + } + + if (type === 'depositAndOrder' && status === 'failed') { + const transactionId = transactionMeta.id; + + // Extract market context before deleting the pending order preview + const pendingOrder = transactionId + ? this.pendingOrderPreviews[transactionId] + : null; + const marketId = pendingOrder?.analyticsProperties?.marketId; + + if (transactionId) { + delete this.pendingOrderPreviews[transactionId]; + } + + const canUpdateActiveBuyOrder = + this.isCurrentActiveBuyOrder(transactionId); + if (canUpdateActiveBuyOrder) { + const errorMessage = + transactionMeta.error?.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED; + + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + state.activeBuyOrder.error = errorMessage; + state.activeBuyOrder.transactionId = undefined; + } + }); + this.initPayWithAnyToken().catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('handleTransactionSideEffects', { + operation: 'initPayWithAnyToken', + }), + ); + }); + } else { + this.messenger.publish('PredictController:transactionStatusChanged', { + type: 'order', + status: 'failed', + senderAddress: address, + marketId, + }); + } + } + + if (type === 'depositAndOrder' && status === 'rejected') { + const transactionId = transactionMeta.id; + if (transactionId) { + delete this.pendingOrderPreviews[transactionId]; + } + + if (this.isCurrentActiveBuyOrder(transactionId)) { + this.onPlaceOrderEnd(); + } + } + if (type === 'claim' && isTerminal) { this.clearPendingClaimForAddress({ address }); } @@ -2446,6 +2729,7 @@ export class PredictController extends BaseController< Record > = { [TransactionType.predictDeposit]: 'deposit', + [TransactionType.predictDepositAndOrder]: 'depositAndOrder', [TransactionType.predictClaim]: 'claim', [TransactionType.predictWithdraw]: 'withdraw', }; diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts index 41199bc7d2f..30df5665e9b 100644 --- a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts +++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts @@ -2,16 +2,14 @@ import { renderHook, act } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { usePredictActiveOrder } from './usePredictActiveOrder'; -import { ActiveOrderState, Recurrence } from '../types'; -import { PredictTradeStatus } from '../constants/eventNames'; +import { ActiveOrderState } from '../types'; jest.mock('../../../../core/Engine', () => ({ context: { PredictController: { - setActiveOrder: jest.fn(), clearActiveOrder: jest.fn(), - setSelectedPaymentToken: jest.fn(), - trackPredictOrderEvent: jest.fn(), + clearOrderError: jest.fn(), + initializeOrder: jest.fn(), }, }, })); @@ -20,351 +18,142 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('../utils/analytics', () => ({ - parseAnalyticsProperties: jest.fn(() => ({ marketId: 'market-1' })), -})); - const mockUseSelector = useSelector as jest.MockedFunction; describe('usePredictActiveOrder', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseSelector.mockReturnValue(undefined); - }); - - describe('updateActiveOrder', () => { - it('sets full order when state property is present', () => { - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ state: ActiveOrderState.PREVIEW }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith({ state: ActiveOrderState.PREVIEW }); - }); - - it('clears activeOrder when called with null', () => { - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder(null); - }); + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: { + state: ActiveOrderState.PREVIEW, + }, + }, + }, + }, + }); + } - expect( - Engine.context.PredictController.clearActiveOrder, - ).toHaveBeenCalled(); + return undefined; }); + }); - it('calls clearActiveOrder and setSelectedPaymentToken(null) when null', () => { + describe('clearOrderError', () => { + it('delegates to PredictController.clearOrderError', () => { const { result } = renderHook(() => usePredictActiveOrder()); act(() => { - result.current.updateActiveOrder(null); + result.current.clearOrderError(); }); expect( - Engine.context.PredictController.clearActiveOrder, + Engine.context.PredictController.clearOrderError, ).toHaveBeenCalled(); - expect( - Engine.context.PredictController.setSelectedPaymentToken, - ).toHaveBeenCalledWith(null); - }); - - it('deletes amount property when amount is null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - amount: '100', - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ amount: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('amount'); - }); - - it('deletes batchId property when batchId is null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - batchId: 'batch-123', - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ batchId: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('batchId'); - }); - - it('deletes isInputFocused when null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ isInputFocused: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('isInputFocused'); - }); - - it('deletes state when null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ state: null }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith(null); - }); - - it('deletes error when null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - error: 'some error', - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ error: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('error'); }); + }); - it('merges patch with existing activeOrder state', () => { - mockUseSelector.mockReturnValue({ + describe('return values', () => { + it('returns activeOrder from useSelector', () => { + const mockActiveOrder = { state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ - state: ActiveOrderState.PLACING_ORDER, - }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith({ - state: ActiveOrderState.PLACING_ORDER, - isInputFocused: true, - }); - }); + }; + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: mockActiveOrder, + }, + }, + }, + }); + } - it('passes null to setActiveOrder when state is removed from nextOrder', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, + return undefined; }); const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.updateActiveOrder({ state: null }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith(null); + expect(result.current.activeOrder).toEqual(mockActiveOrder); }); - }); - - describe('initializeActiveOrder', () => { - it('sets state to PREVIEW and isInputFocused to true', () => { - const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.initializeActiveOrder({ - market: { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open', - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }, - outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 }, - }); - }); + it('returns isDepositing when active order is depositing', () => { + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: { + state: ActiveOrderState.DEPOSITING, + }, + }, + }, + }, + }); + } - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, + return undefined; }); - }); - it('calls setSelectedPaymentToken with null', () => { const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.initializeActiveOrder({ - market: { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open', - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }, - outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 }, - }); - }); - - expect( - Engine.context.PredictController.setSelectedPaymentToken, - ).toHaveBeenCalledWith(null); + expect(result.current.isDepositing).toBe(true); + expect(result.current.isPlacingOrder).toBe(true); }); - it('calls trackPredictOrderEvent with INITIATED status', () => { - const { result } = renderHook(() => usePredictActiveOrder()); + it('returns isPlacingOrder when active order is placing order', () => { + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: { + state: ActiveOrderState.PLACING_ORDER, + }, + }, + }, + }, + }); + } - act(() => { - result.current.initializeActiveOrder({ - market: { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open', - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }, - outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 }, - }); + return undefined; }); - expect( - Engine.context.PredictController.trackPredictOrderEvent, - ).toHaveBeenCalledWith( - expect.objectContaining({ status: PredictTradeStatus.INITIATED }), - ); - }); - - it('passes parsed analytics properties from market/outcomeToken/entryPoint', () => { - const { parseAnalyticsProperties } = jest.requireMock( - '../utils/analytics', - ) as { parseAnalyticsProperties: jest.Mock }; - - const mockMarket = { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open' as const, - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }; - const mockOutcomeToken = { id: 'token-1', title: 'Yes', price: 0.6 }; - const mockEntryPoint = 'carousel' as const; - const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.initializeActiveOrder({ - market: mockMarket, - outcomeToken: mockOutcomeToken, - entryPoint: mockEntryPoint, - }); - }); - - expect(parseAnalyticsProperties).toHaveBeenCalledWith( - mockMarket, - mockOutcomeToken, - mockEntryPoint, - ); - expect( - Engine.context.PredictController.trackPredictOrderEvent, - ).toHaveBeenCalledWith({ - status: PredictTradeStatus.INITIATED, - analyticsProperties: { marketId: 'market-1' }, - }); + expect(result.current.isDepositing).toBe(false); + expect(result.current.isPlacingOrder).toBe(true); }); - }); - describe('clearActiveOrder', () => { - it('calls PredictController.clearActiveOrder', () => { - const { result } = renderHook(() => usePredictActiveOrder()); + it('returns false flags when there is no active buy order', () => { + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: null, + }, + }, + }, + }); + } - act(() => { - result.current.clearActiveOrder(); + return undefined; }); - expect( - Engine.context.PredictController.clearActiveOrder, - ).toHaveBeenCalled(); - }); - }); - - describe('return values', () => { - it('returns activeOrder from useSelector', () => { - const mockActiveOrder = { - state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }; - mockUseSelector.mockReturnValue(mockActiveOrder); - const { result } = renderHook(() => usePredictActiveOrder()); - expect(result.current.activeOrder).toEqual(mockActiveOrder); + expect(result.current.activeOrder).toBeNull(); + expect(result.current.isDepositing).toBe(false); + expect(result.current.isPlacingOrder).toBe(false); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts index 35f6020d759..9ddac26d0b5 100644 --- a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts @@ -1,21 +1,10 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; -import { PredictControllerState } from '../controllers/PredictController'; -import { selectPredictActiveOrder } from '../selectors/predictController'; -import { parseAnalyticsProperties } from '../utils/analytics'; -import { PredictTradeStatus } from '../constants/eventNames'; +import { selectPredictActiveBuyOrder } from '../selectors/predictController'; import { ActiveOrderState, PredictMarket, PredictOutcomeToken } from '../types'; import { PredictEntryPoint } from '../types/navigation'; -type PredictActiveOrder = PredictControllerState['activeOrder']; -type PredictActiveOrderValue = NonNullable; -type PredictActiveOrderPatch = - | { - [K in keyof PredictActiveOrderValue]?: PredictActiveOrderValue[K] | null; - } - | null; - export interface InitializeActiveOrderParams { market: PredictMarket; outcomeToken: PredictOutcomeToken; @@ -25,97 +14,28 @@ export interface InitializeActiveOrderParams { export const usePredictActiveOrder = () => { const { PredictController } = Engine.context; - const activeOrder = useSelector(selectPredictActiveOrder); - - const activeOrderRef = useRef(activeOrder); - activeOrderRef.current = activeOrder; - - const updateActiveOrder = useCallback( - (order: PredictActiveOrderPatch) => { - if (order === null) { - PredictController.clearActiveOrder(); - PredictController.setSelectedPaymentToken(null); - return; - } - - const nextOrder: Partial = { - ...(activeOrderRef.current ?? {}), - }; - - if ('amount' in order) { - if (order.amount === null) { - delete nextOrder.amount; - } else { - nextOrder.amount = order.amount; - } - } + const activeOrder = useSelector(selectPredictActiveBuyOrder); - if ('batchId' in order) { - if (order.batchId === null) { - delete nextOrder.batchId; - } else { - nextOrder.batchId = order.batchId; - } - } - - if ('isInputFocused' in order) { - if (order.isInputFocused === null) { - delete nextOrder.isInputFocused; - } else { - nextOrder.isInputFocused = order.isInputFocused; - } - } - - if ('state' in order) { - if (order.state === null) { - delete nextOrder.state; - } else { - nextOrder.state = order.state; - } - } + const clearOrderError = useCallback(() => { + PredictController.clearOrderError(); + }, [PredictController]); - if ('error' in order) { - if (order.error === null) { - delete nextOrder.error; - } else { - nextOrder.error = order.error; - } - } + const currentState = useMemo(() => activeOrder?.state, [activeOrder]); - PredictController.setActiveOrder( - nextOrder.state ? (nextOrder as PredictActiveOrderValue) : null, - ); - }, - [PredictController], + const isDepositing = useMemo( + () => currentState === ActiveOrderState.DEPOSITING, + [currentState], ); - const initializeActiveOrder = useCallback( - (params: InitializeActiveOrderParams) => { - updateActiveOrder({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }); - PredictController.setSelectedPaymentToken(null); - PredictController.trackPredictOrderEvent({ - status: PredictTradeStatus.INITIATED, - analyticsProperties: parseAnalyticsProperties( - params.market, - params.outcomeToken, - params.entryPoint, - ), - }); - }, - [updateActiveOrder, PredictController], + const isPlacingOrder = useMemo( + () => currentState === ActiveOrderState.PLACING_ORDER || isDepositing, + [currentState, isDepositing], ); - const clearActiveOrder = useCallback(() => { - PredictController.clearActiveOrder(); - }, [PredictController]); - return { activeOrder, - updateActiveOrder, - clearActiveOrder, - initializeActiveOrder, + isDepositing, + isPlacingOrder, + clearOrderError, }; }; diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts index 8a1ae0b2edb..de9e31a6bb9 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import { AssetType } from '../../../Views/confirmations/types/token'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; import { @@ -36,13 +37,22 @@ jest.mock('../../SimulationDetails/FiatDisplay/useFiatFormatter', () => ({ `$${Number(value.toString()).toFixed(2)}`, })); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + jest.mock('../../../Views/confirmations/utils/transaction', () => ({ hasTransactionType: jest.fn(), })); +jest.mock('../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(() => 'polygon-network-badge'), +})); + const mockHasTransactionType = hasTransactionType as jest.MockedFunction< typeof hasTransactionType >; +const mockUseSelector = useSelector as jest.MockedFunction; const createMockToken = (overrides?: Partial): AssetType => ({ address: '0xtoken1', @@ -68,6 +78,7 @@ describe('usePredictBalanceTokenFilter', () => { mockPredictBalance = 100; mockTransactionMeta = null; mockHasTransactionType.mockReturnValue(false); + mockUseSelector.mockReturnValue({ image: 'usdce-token-image' }); }); it('returns original tokens when transaction type does not match and forceEnabled is false', () => { @@ -176,4 +187,26 @@ describe('usePredictBalanceTokenFilter', () => { expect(filteredTokens[0].symbol).toBe('USDC.e'); }); + + it('uses empty string for image when usdceToken is null', () => { + mockHasTransactionType.mockReturnValue(true); + mockUseSelector.mockReturnValue(null); + const tokens = [createMockToken()]; + + const { result } = renderHook(() => usePredictBalanceTokenFilter()); + const filteredTokens = result.current(tokens); + + expect(filteredTokens[0].image).toBe(''); + expect(filteredTokens[0].logo).toBe(''); + }); + + it('adds the polygon network badge to the synthetic token', () => { + mockHasTransactionType.mockReturnValue(true); + const tokens = [createMockToken()]; + + const { result } = renderHook(() => usePredictBalanceTokenFilter()); + const filteredTokens = result.current(tokens); + + expect(filteredTokens[0].networkBadgeSource).toBe('polygon-network-badge'); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts index 7aa3ac7f69a..39de614cbe8 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts @@ -1,6 +1,12 @@ import { BigNumber } from 'bignumber.js'; import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../../../reducers'; +import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; +import { getNetworkImageSource } from '../../../../util/networks'; import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; +import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict'; +import { TransactionType } from '@metamask/transaction-controller'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType } from '../../../Views/confirmations/types/token'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; @@ -10,10 +16,6 @@ import { } from '../constants/transactions'; import { usePredictBalance } from './usePredictBalance'; import { usePredictPaymentToken } from './usePredictPaymentToken'; -import { TransactionType } from '@metamask/transaction-controller'; - -//TODO: Remove this once the predictDepositAndOrder type is added to the transaction controller -const PREDICT_DEPOSIT_AND_ORDER_TYPE = 'predictDepositAndOrder'; export function usePredictBalanceTokenFilter( forceEnabled = false, @@ -22,14 +24,20 @@ export function usePredictBalanceTokenFilter( const { isPredictBalanceSelected } = usePredictPaymentToken(); const { data: predictBalance = 0 } = usePredictBalance(); const formatFiat = useFiatFormatter({ currency: 'usd' }); + const usdceToken = useSelector((state: RootState) => + selectSingleTokenByAddressAndChainId( + state, + POLYGON_USDCE.address, + PREDICT_BALANCE_CHAIN_ID, + ), + ); return useCallback( (tokens: AssetType[]): AssetType[] => { if ( !forceEnabled && !hasTransactionType(transactionMeta, [ - // TODO: Remove this once the predictDepositAndOrder type is added to the transaction controller - PREDICT_DEPOSIT_AND_ORDER_TYPE as TransactionType, + TransactionType.predictDepositAndOrder, ]) ) { return tokens; @@ -46,8 +54,11 @@ export function usePredictBalanceTokenFilter( symbol: 'USDC.e', balance: balanceStr, balanceInSelectedCurrency: balanceFormatted, - image: '', - logo: '', + image: usdceToken?.image ?? '', + logo: usdceToken?.image ?? '', + networkBadgeSource: getNetworkImageSource({ + chainId: PREDICT_BALANCE_CHAIN_ID, + }), decimals: 6, isETH: false, isNative: false, @@ -70,6 +81,7 @@ export function usePredictBalanceTokenFilter( isPredictBalanceSelected, predictBalance, formatFiat, + usdceToken, ], ); } diff --git a/app/components/UI/Predict/hooks/usePredictClaim.test.ts b/app/components/UI/Predict/hooks/usePredictClaim.test.ts index 6842d3ca917..0088cb4e308 100644 --- a/app/components/UI/Predict/hooks/usePredictClaim.test.ts +++ b/app/components/UI/Predict/hooks/usePredictClaim.test.ts @@ -159,8 +159,8 @@ describe('usePredictClaim', () => { getBalance: jest.fn(), previewOrder: jest.fn(), deposit: jest.fn(), - payWithAnyTokenConfirmation: jest.fn(), prepareWithdraw: jest.fn(), + initPayWithAnyToken: jest.fn(), } as ReturnType); mockUseConfirmNavigation.mockReturnValue({ diff --git a/app/components/UI/Predict/hooks/usePredictNavigation.test.ts b/app/components/UI/Predict/hooks/usePredictNavigation.test.ts index a24742bbffe..e6adc094d80 100644 --- a/app/components/UI/Predict/hooks/usePredictNavigation.test.ts +++ b/app/components/UI/Predict/hooks/usePredictNavigation.test.ts @@ -7,7 +7,6 @@ import { PredictMarket, PredictOutcome, PredictOutcomeToken } from '../types'; const mockNavigate = jest.fn(); const mockDispatch = jest.fn(); -const mockInitializeActiveOrder = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -17,15 +16,6 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('./usePredictActiveOrder', () => ({ - usePredictActiveOrder: () => ({ - initializeActiveOrder: mockInitializeActiveOrder, - activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), - }), -})); - const createMockParams = ( overrides?: Partial, ): PredictBuyPreviewParams => ({ @@ -99,26 +89,6 @@ describe('usePredictNavigation', () => { ); }); - it('passes all params to the navigation call', () => { - const { result } = renderHook(() => usePredictNavigation()); - const params = createMockParams({ - isConfirmation: true, - animationEnabled: false, - }); - - act(() => { - result.current.navigateToBuyPreview(params); - }); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.PREDICT.MODALS.BUY_PREVIEW, - expect.objectContaining({ - isConfirmation: true, - animationEnabled: false, - }), - ); - }); - it('passes all params through ROOT navigation', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams({ @@ -137,22 +107,6 @@ describe('usePredictNavigation', () => { }); }); - it('dispatches StackActions.replace when replace option is true', () => { - const { result } = renderHook(() => usePredictNavigation()); - const params = createMockParams({ - animationEnabled: false, - }); - - act(() => { - result.current.navigateToBuyPreview(params, { replace: true }); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params), - ); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - it('replace takes precedence over throughRoot', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -170,7 +124,7 @@ describe('usePredictNavigation', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it('calls initializeActiveOrder on direct navigation', () => { + it('does not initialize active order on direct navigation', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -178,14 +132,10 @@ describe('usePredictNavigation', () => { result.current.navigateToBuyPreview(params); }); - expect(mockInitializeActiveOrder).toHaveBeenCalledWith({ - market: params.market, - outcomeToken: params.outcomeToken, - entryPoint: params.entryPoint, - }); + expect(mockDispatch).not.toHaveBeenCalled(); }); - it('calls initializeActiveOrder on throughRoot navigation', () => { + it('does not dispatch a replace action on throughRoot navigation', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -193,14 +143,10 @@ describe('usePredictNavigation', () => { result.current.navigateToBuyPreview(params, { throughRoot: true }); }); - expect(mockInitializeActiveOrder).toHaveBeenCalledWith({ - market: params.market, - outcomeToken: params.outcomeToken, - entryPoint: params.entryPoint, - }); + expect(mockDispatch).not.toHaveBeenCalled(); }); - it('does not call initializeActiveOrder on replace navigation', () => { + it('dispatches replace navigation when replace is true', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -208,7 +154,7 @@ describe('usePredictNavigation', () => { result.current.navigateToBuyPreview(params, { replace: true }); }); - expect(mockInitializeActiveOrder).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictNavigation.ts b/app/components/UI/Predict/hooks/usePredictNavigation.ts index a50695caf12..ef723eb2497 100644 --- a/app/components/UI/Predict/hooks/usePredictNavigation.ts +++ b/app/components/UI/Predict/hooks/usePredictNavigation.ts @@ -2,7 +2,6 @@ import { StackActions, useNavigation } from '@react-navigation/native'; import { useCallback } from 'react'; import Routes from '../../../../constants/navigation/Routes'; import { PredictBuyPreviewParams } from '../types/navigation'; -import { usePredictActiveOrder } from './usePredictActiveOrder'; interface NavigateToBuyPreviewOptions { throughRoot?: boolean; @@ -11,7 +10,6 @@ interface NavigateToBuyPreviewOptions { export const usePredictNavigation = () => { const navigation = useNavigation(); - const { initializeActiveOrder } = usePredictActiveOrder(); const navigateToBuyPreview = useCallback( ( @@ -22,24 +20,16 @@ export const usePredictNavigation = () => { navigation.dispatch( StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params), ); - } else { - initializeActiveOrder({ - market: params.market, - outcomeToken: params.outcomeToken, - entryPoint: params.entryPoint, + } else if (options?.throughRoot) { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params, }); - - if (options?.throughRoot) { - navigation.navigate(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MODALS.BUY_PREVIEW, - params, - }); - } else { - navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, params); - } + } else { + navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, params); } }, - [navigation, initializeActiveOrder], + [navigation], ); return { navigateToBuyPreview }; diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts index e89a4ad0b83..7489f49772b 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts @@ -92,54 +92,6 @@ describe('usePredictOrderPreview', () => { expect(result.current.error).toBeNull(); }); - it('initializes with initialPreview when provided', () => { - const { Wrapper } = createWrapper(); - const { result } = renderHook( - () => - usePredictOrderPreview({ - ...defaultParams, - initialPreview: mockPreview, - }), - { wrapper: Wrapper }, - ); - - expect(result.current.preview).toEqual(mockPreview); - expect(result.current.isCalculating).toBe(true); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBeNull(); - }); - - it('replaces initialPreview when new preview loads from API', async () => { - const { Wrapper } = createWrapper(); - const updatedPreview: OrderPreview = { - ...mockPreview, - sharePrice: 0.75, - maxAmountSpent: 200, - }; - mockPreviewOrder.mockResolvedValue(updatedPreview); - - const { result } = renderHook( - () => - usePredictOrderPreview({ - ...defaultParams, - initialPreview: mockPreview, - }), - { wrapper: Wrapper }, - ); - - expect(result.current.preview).toEqual(mockPreview); - - act(() => { - jest.advanceTimersByTime(100); - }); - - await waitFor(() => { - expect(result.current.preview).toEqual(updatedPreview); - }); - - expect(result.current.isLoading).toBe(false); - }); - it('calculates preview when size is valid', async () => { const { Wrapper } = createWrapper(); const { result } = renderHook( @@ -350,15 +302,11 @@ describe('usePredictOrderPreview', () => { }); describe('error handling', () => { - it('does not log an error when only initialPreview is provided', async () => { + it('does not log an error when preview loads from API', async () => { const { Wrapper } = createWrapper(); const { result } = renderHook( - () => - usePredictOrderPreview({ - ...defaultParams, - initialPreview: mockPreview, - }), + () => usePredictOrderPreview(defaultParams), { wrapper: Wrapper }, ); diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts index e32b931bf83..80d8c6605ea 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts @@ -21,7 +21,6 @@ interface OrderPreviewResult { export function usePredictOrderPreview( params: PreviewOrderParams & { autoRefreshTimeout?: number; - initialPreview?: OrderPreview | null; }, ): OrderPreviewResult { // Destructure params for stable dependencies @@ -68,9 +67,7 @@ export function usePredictOrderPreview( hasValidSize && autoRefreshTimeout ? autoRefreshTimeout : false, }); - const preview = hasValidSize - ? (query.data ?? params.initialPreview ?? null) - : (params.initialPreview ?? null); + const preview = hasValidSize ? (query.data ?? null) : null; const error = query.error ? parseErrorMessage({ error: query.error, diff --git a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts deleted file mode 100644 index 8ff7254ab12..00000000000 --- a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { act, renderHook } from '@testing-library/react-native'; -import React from 'react'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { PredictBuyPreviewParams } from '../types/navigation'; -import { usePredictPayWithAnyToken } from './usePredictPayWithAnyToken'; -import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; - -const mockGoBack = jest.fn(); -const mockPayWithAnyTokenConfirmation = jest.fn(); -const mockNavigateToConfirmation = jest.fn(); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - goBack: mockGoBack, - }), -})); - -jest.mock('./usePredictTrading', () => ({ - usePredictTrading: () => ({ - payWithAnyTokenConfirmation: mockPayWithAnyTokenConfirmation, - }), -})); - -jest.mock('../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ - useConfirmNavigation: () => ({ - navigateToConfirmation: mockNavigateToConfirmation, - }), -})); - -jest.mock('../../../Views/confirmations/hooks/tokens/useAddToken', () => ({ - useAddToken: jest.fn(), -})); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -jest.mock('../../../../util/theme', () => ({ - useAppThemeFromContext: () => ({ - colors: { - error: { default: 'red' }, - accent04: { normal: 'black' }, - }, - }), -})); - -const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - ToastContext.Provider, - { - value: { - toastRef: { - current: { - showToast: mockShowToast, - closeToast: mockCloseToast, - }, - }, - }, - }, - children, - ); - -describe('usePredictPayWithAnyToken', () => { - const market = { id: 'market-1' } as PredictBuyPreviewParams['market']; - const outcome = { id: 'outcome-1' } as PredictBuyPreviewParams['outcome']; - const outcomeToken = { - id: 'token-1', - } as PredictBuyPreviewParams['outcomeToken']; - - beforeEach(() => { - jest.clearAllMocks(); - mockPayWithAnyTokenConfirmation.mockResolvedValue({ response: {} }); - }); - - it('triggers payWithAnyTokenConfirmation and navigates to confirmation', async () => { - const { result } = renderHook(() => usePredictPayWithAnyToken(), { - wrapper, - }); - - await act(async () => { - result.current.triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - }); - }); - - expect(mockPayWithAnyTokenConfirmation).toHaveBeenCalledWith(); - expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ - loader: ConfirmationLoader.CustomAmount, - headerShown: false, - }); - expect(mockGoBack).not.toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('goes back and shows error toast when payWithAnyTokenConfirmation fails', async () => { - mockPayWithAnyTokenConfirmation.mockImplementation(() => { - throw new Error('boom'); - }); - - const { result } = renderHook(() => usePredictPayWithAnyToken(), { - wrapper, - }); - - await act(async () => { - result.current.triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - }); - }); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockShowToast).toHaveBeenCalledTimes(1); - expect(mockNavigateToConfirmation).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts deleted file mode 100644 index ef371409a9a..00000000000 --- a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; -import { useCallback, useContext } from 'react'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import Logger from '../../../../util/Logger'; -import { useAppThemeFromContext } from '../../../../util/theme'; -import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; -import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict'; -import { useAddToken } from '../../../Views/confirmations/hooks/tokens/useAddToken'; -import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation'; -import { PREDICT_CONSTANTS } from '../constants/errors'; -import { - PredictBuyPreviewParams, - PredictNavigationParamList, -} from '../types/navigation'; -import { - createDepositErrorToast, - ensureError, -} from '../utils/predictErrorHandler'; -import { usePredictTrading } from './usePredictTrading'; -import { OrderPreview } from '../types'; - -export interface PredictPayWithAnyTokenParams { - market: PredictBuyPreviewParams['market']; - outcome: PredictBuyPreviewParams['outcome']; - outcomeToken: PredictBuyPreviewParams['outcomeToken']; - preview?: OrderPreview; -} - -interface UsePredictPayWithAnyTokenResult { - triggerPayWithAnyToken: (params: PredictPayWithAnyTokenParams) => void; -} - -export function usePredictPayWithAnyToken(): UsePredictPayWithAnyTokenResult { - const { navigateToConfirmation } = useConfirmNavigation(); - const theme = useAppThemeFromContext(); - const { toastRef } = useContext(ToastContext); - const navigation = - useNavigation>(); - - useAddToken({ - chainId: CHAIN_IDS.POLYGON, - decimals: POLYGON_USDCE.decimals, - name: POLYGON_USDCE.name, - symbol: POLYGON_USDCE.symbol, - tokenAddress: POLYGON_USDCE.address, - }); - - const { payWithAnyTokenConfirmation } = usePredictTrading(); - - const handleDepositError = useCallback( - (err: unknown, action: string) => { - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictPayWithAnyToken', - }, - context: { - name: 'usePredictPayWithAnyToken', - data: { - method: 'triggerPayWithAnyToken', - action, - operation: 'financial_operations', - }, - }, - }); - - navigation.goBack(); - toastRef?.current?.showToast(createDepositErrorToast(theme)); - }, - [navigation, theme, toastRef], - ); - - const triggerPayWithAnyToken = useCallback( - //(params: PredictPayWithAnyTokenParams) => { - () => { - // TODO: Uncomment this when the confirmation screen is ready - try { - payWithAnyTokenConfirmation(); - navigateToConfirmation({ - loader: ConfirmationLoader.CustomAmount, - headerShown: false, - /* replace: true, - routeParams: { - market: params.market, - outcome: params.outcome, - outcomeToken: params.outcomeToken, - isConfirmation: true, - preview: params.preview, - }, */ - }); - } catch (err) { - handleDepositError(err, 'pay_with_any_token'); - } - }, - [payWithAnyTokenConfirmation, handleDepositError, navigateToConfirmation], - ); - - return { - triggerPayWithAnyToken, - }; -} diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts index 5ec5ccd0533..ad6ecc5bc3d 100644 --- a/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts @@ -1,6 +1,5 @@ import { act, renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; -import { Hex } from '@metamask/utils'; import { usePredictPaymentToken } from './usePredictPaymentToken'; import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import Engine from '../../../../core/Engine'; @@ -11,9 +10,6 @@ let mockSelectedPaymentToken: { chainId: string; symbol?: string; } | null = null; -let mockTransactionMeta: { id: string } | null = null; -let mockPayToken: { address: Hex; chainId: Hex } | null = null; -const mockSetPayToken = jest.fn(); const createMockAsset = (overrides?: Partial): AssetType => ({ address: '0x1234', @@ -32,26 +28,10 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock( - '../../../Views/confirmations/hooks/pay/useTransactionPayToken', - () => ({ - useTransactionPayToken: () => ({ - payToken: mockPayToken, - setPayToken: mockSetPayToken, - }), - }), -); - -jest.mock( - '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', - () => ({ - useTransactionMetadataRequest: () => mockTransactionMeta, - }), -); - jest.mock('../../../../core/Engine', () => ({ context: { PredictController: { + selectPaymentToken: jest.fn(), setSelectedPaymentToken: jest.fn(), }, }, @@ -61,95 +41,19 @@ describe('usePredictPaymentToken', () => { beforeEach(() => { jest.clearAllMocks(); mockSelectedPaymentToken = null; - mockTransactionMeta = null; - mockPayToken = null; jest.mocked(useSelector).mockImplementation(() => mockSelectedPaymentToken); jest .mocked(Engine.context.PredictController.setSelectedPaymentToken) .mockClear(); + jest + .mocked(Engine.context.PredictController.selectPaymentToken) + .mockClear(); }); afterEach(() => { jest.resetAllMocks(); }); - it('does not call onTokenSelected on initial render', () => { - const onTokenSelected = jest.fn(); - - renderHook(() => usePredictPaymentToken({ onTokenSelected })); - - expect(onTokenSelected).not.toHaveBeenCalled(); - }); - - it('calls onTokenSelected when token changes from predict balance to token', async () => { - const onTokenSelected = jest.fn(); - const { rerender } = renderHook( - ({ onTokenSelected: selectedCallback }) => - usePredictPaymentToken({ onTokenSelected: selectedCallback }), - { - initialProps: { onTokenSelected }, - }, - ); - - mockSelectedPaymentToken = { - address: '0x1234', - chainId: '0x1', - }; - - await act(async () => { - rerender({ onTokenSelected }); - }); - - expect(onTokenSelected).toHaveBeenCalledWith({ - tokenAddress: '0x1234', - tokenKey: '0x1234', - }); - }); - - it('calls onTokenSelected with predict-balance key when switching back to predict balance', async () => { - mockSelectedPaymentToken = { - address: '0x1234', - chainId: '0x1', - }; - - const onTokenSelected = jest.fn(); - const { rerender } = renderHook( - ({ onTokenSelected: selectedCallback }) => - usePredictPaymentToken({ onTokenSelected: selectedCallback }), - { - initialProps: { onTokenSelected }, - }, - ); - - mockSelectedPaymentToken = null; - - await act(async () => { - rerender({ onTokenSelected }); - }); - - expect(onTokenSelected).toHaveBeenCalledWith({ - tokenAddress: null, - tokenKey: 'predict-balance', - }); - }); - - it('does not call onTokenSelected when token selection does not change', async () => { - const onTokenSelected = jest.fn(); - const { rerender } = renderHook( - ({ onTokenSelected: selectedCallback }) => - usePredictPaymentToken({ onTokenSelected: selectedCallback }), - { - initialProps: { onTokenSelected }, - }, - ); - - await act(async () => { - rerender({ onTokenSelected }); - }); - - expect(onTokenSelected).not.toHaveBeenCalled(); - }); - describe('onPaymentTokenChange', () => { it('returns early when token is null', () => { const { result } = renderHook(() => usePredictPaymentToken()); @@ -159,32 +63,14 @@ describe('usePredictPaymentToken', () => { }); expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), + jest.mocked(Engine.context.PredictController.selectPaymentToken), ).not.toHaveBeenCalled(); }); - it('calls setSelectedPaymentToken with null when token address is placeholder', () => { - const { result } = renderHook(() => usePredictPaymentToken()); - - act(() => { - result.current.onPaymentTokenChange( - createMockAsset({ - address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS, - }), - ); - }); - - expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), - ).toHaveBeenCalledWith(null); - }); - - it('calls setSelectedPaymentToken with token data when token is valid', () => { + it('calls selectPaymentToken with full token for balance placeholder', () => { const { result } = renderHook(() => usePredictPaymentToken()); const token = createMockAsset({ - address: '0xabcd', - chainId: '0x1', - symbol: 'TEST', + address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS, }); act(() => { @@ -192,63 +78,32 @@ describe('usePredictPaymentToken', () => { }); expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), - ).toHaveBeenCalledWith({ - address: '0xabcd', - chainId: '0x1', - symbol: 'TEST', - }); + jest.mocked(Engine.context.PredictController.selectPaymentToken), + ).toHaveBeenCalledWith( + expect.objectContaining({ + address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS, + }), + ); }); - it('calls setPayToken when transactionMeta.id exists', () => { - mockTransactionMeta = { id: 'tx-123' }; - const { result } = renderHook(() => usePredictPaymentToken()); - const token = createMockAsset({ - address: '0xabcd', - chainId: '0x1', - }); - - act(() => { - result.current.onPaymentTokenChange(token); - }); - - expect(mockSetPayToken).toHaveBeenCalledWith({ - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }); - }); - - it('does not call setPayToken when transactionMeta is null', () => { - mockTransactionMeta = null; - const { result } = renderHook(() => usePredictPaymentToken()); - const token = createMockAsset({ - address: '0xabcd', - chainId: '0x1', - }); - - act(() => { - result.current.onPaymentTokenChange(token); - }); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('does not call setPayToken when transactionMeta.id is missing', () => { - mockTransactionMeta = { id: '' }; + it('calls selectPaymentToken with full token for valid token', () => { const { result } = renderHook(() => usePredictPaymentToken()); const token = createMockAsset({ address: '0xabcd', chainId: '0x1', + symbol: 'TEST', }); act(() => { result.current.onPaymentTokenChange(token); }); - expect(mockSetPayToken).not.toHaveBeenCalled(); + expect( + jest.mocked(Engine.context.PredictController.selectPaymentToken), + ).toHaveBeenCalledWith(token); }); - it('handles token with missing chainId', () => { + it('passes token with missing chainId to controller', () => { const { result } = renderHook(() => usePredictPaymentToken()); const token = createMockAsset({ address: '0xabcd', @@ -261,12 +116,8 @@ describe('usePredictPaymentToken', () => { }); expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), - ).toHaveBeenCalledWith({ - address: '0xabcd', - chainId: '', - symbol: undefined, - }); + jest.mocked(Engine.context.PredictController.selectPaymentToken), + ).toHaveBeenCalledWith(token); }); }); @@ -284,105 +135,6 @@ describe('usePredictPaymentToken', () => { }); }); - describe('useEffect syncing payToken with selectedPaymentToken', () => { - it('skips sync when transactionMeta is missing', () => { - mockTransactionMeta = null; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x1', - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when isPredictBalanceSelected is true', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = null; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when selectedPaymentToken is null', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = null; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when token is already applied', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x1', - }; - mockPayToken = { - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when token is already applied with different case', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xABCD', - chainId: '0x1', - }; - mockPayToken = { - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('calls setPayToken when token is not yet applied', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x1', - }; - mockPayToken = null; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).toHaveBeenCalledWith({ - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }); - }); - - it('calls setPayToken when chainId differs', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x2', - }; - mockPayToken = { - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).toHaveBeenCalledWith({ - address: '0xabcd' as Hex, - chainId: '0x2' as Hex, - }); - }); - }); - describe('isPredictBalanceSelected', () => { it('returns true when selectedPaymentToken is null', () => { mockSelectedPaymentToken = null; diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts index 8c073eea7bd..bed5c069153 100644 --- a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts +++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts @@ -1,26 +1,9 @@ -import { Hex } from '@metamask/utils'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; -import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/useTransactionPayToken'; -import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType } from '../../../Views/confirmations/types/token'; -import { - PREDICT_BALANCE_PLACEHOLDER_ADDRESS, - PREDICT_BALANCE_TOKEN_KEY, -} from '../constants/transactions'; import { selectPredictSelectedPaymentToken } from '../selectors/predictController'; -interface UsePredictPaymentTokenParams { - onTokenSelected?: ({ - tokenAddress, - tokenKey, - }: { - tokenAddress: string | null; - tokenKey: string | null; - }) => Promise | void; -} - export interface UsePredictPaymentTokenResult { onPaymentTokenChange: (token: AssetType | null) => void; isPredictBalanceSelected: boolean; @@ -32,15 +15,9 @@ export interface UsePredictPaymentTokenResult { resetSelectedPaymentToken: () => void; } -export function usePredictPaymentToken({ - onTokenSelected, -}: UsePredictPaymentTokenParams = {}): UsePredictPaymentTokenResult { - const { payToken, setPayToken } = useTransactionPayToken(); - const transactionMeta = useTransactionMetadataRequest(); +export function usePredictPaymentToken(): UsePredictPaymentTokenResult { const selectedPaymentToken = useSelector(selectPredictSelectedPaymentToken); const isPredictBalanceSelected = selectedPaymentToken === null; - const hasInitializedSelectionRef = useRef(false); - const previousSelectedTokenKeyRef = useRef(null); const { PredictController } = Engine.context; @@ -50,83 +27,11 @@ export function usePredictPaymentToken({ return; } - if (token.address === PREDICT_BALANCE_PLACEHOLDER_ADDRESS) { - PredictController.setSelectedPaymentToken(null); - return; - } - - PredictController.setSelectedPaymentToken({ - address: token.address, - chainId: token.chainId ?? '', - symbol: token.symbol, - }); - if (transactionMeta?.id) { - setPayToken({ - address: token.address as Hex, - chainId: (token.chainId ?? '') as Hex, - }); - } + PredictController.selectPaymentToken(token); }, - [PredictController, setPayToken, transactionMeta?.id], + [PredictController], ); - useEffect(() => { - if (!transactionMeta || isPredictBalanceSelected || !selectedPaymentToken) { - return; - } - - const hasSelectedTokenApplied = - payToken?.address?.toLowerCase() === - selectedPaymentToken.address.toLowerCase() && - payToken?.chainId?.toLowerCase() === - selectedPaymentToken.chainId.toLowerCase(); - - if (!hasSelectedTokenApplied) { - setPayToken({ - address: selectedPaymentToken.address as Hex, - chainId: selectedPaymentToken.chainId as Hex, - }); - } - }, [ - transactionMeta, - isPredictBalanceSelected, - selectedPaymentToken, - payToken?.address, - payToken?.chainId, - setPayToken, - ]); - - useEffect(() => { - const selectedTokenAddress = selectedPaymentToken?.address ?? null; - const selectedTokenKey = isPredictBalanceSelected - ? PREDICT_BALANCE_TOKEN_KEY - : selectedTokenAddress; - - if (!hasInitializedSelectionRef.current) { - hasInitializedSelectionRef.current = true; - previousSelectedTokenKeyRef.current = selectedTokenKey; - return; - } - - if (previousSelectedTokenKeyRef.current === selectedTokenKey) { - return; - } - - previousSelectedTokenKeyRef.current = selectedTokenKey; - const callbackResult = onTokenSelected?.({ - tokenAddress: selectedTokenAddress, - tokenKey: selectedTokenKey, - }); - - if (callbackResult) { - Promise.resolve(callbackResult).catch(() => undefined); - } - }, [ - isPredictBalanceSelected, - onTokenSelected, - selectedPaymentToken?.address, - ]); - const resetSelectedPaymentToken = useCallback(() => { PredictController.setSelectedPaymentToken(null); }, [PredictController]); diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts index fa07e0c32db..4a9ba0c733a 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts @@ -21,6 +21,7 @@ jest.mock('./usePredictBalance'); jest.mock('./usePredictDeposit'); const mockQueryClient = { invalidateQueries: jest.fn() }; jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), useQueryClient: () => mockQueryClient, })); jest.mock('../../../../../locales/i18n', () => ({ @@ -123,7 +124,7 @@ describe('usePredictPlaceOrder', () => { previewOrder: jest.fn(), prepareWithdraw: jest.fn(), deposit: jest.fn(), - payWithAnyTokenConfirmation: jest.fn(), + initPayWithAnyToken: jest.fn(), }); mockRefetchBalance.mockResolvedValue({ data: 1000 }); mockUsePredictBalance.mockReturnValue({ @@ -149,6 +150,7 @@ describe('usePredictPlaceOrder', () => { expect(result.current.error).toBeUndefined(); expect(result.current.result).toBeNull(); expect(typeof result.current.placeOrder).toBe('function'); + expect(typeof result.current.invalidateOrderQueries).toBe('function'); }); }); @@ -191,6 +193,27 @@ describe('usePredictPlaceOrder', () => { hasNoTimeout: false, }), ); + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'balance'], + }), + ); + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'positions'], + }), + ); + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'activity'], + }), + ); + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); }); it('shows cashed out toast when SELL order is placed', async () => { diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts index c2dde6b1c78..7399a626926 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts @@ -1,28 +1,27 @@ -import { useCallback, useContext, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useContext, useState } from 'react'; +import { strings } from '../../../../../locales/i18n'; import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastContext, ToastVariants, } from '../../../../component-library/components/Toast'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; -import Logger from '../../../../util/Logger'; import { - trace, endTrace, + trace, TraceName, TraceOperation, } from '../../../../util/trace'; +import { PREDICT_CONSTANTS } from '../constants/errors'; +import { PredictEventValues } from '../constants/eventNames'; +import { predictQueries } from '../queries'; import { PlaceOrderParams, Side, type Result } from '../types'; -import { usePredictTrading } from './usePredictTrading'; -import { strings } from '../../../../../locales/i18n'; import { formatPrice } from '../utils/format'; -import { ensureError, parseErrorMessage } from '../utils/predictErrorHandler'; -import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; +import { checkPlaceOrderError } from '../utils/predictErrorHandler'; import { usePredictBalance } from './usePredictBalance'; -import { predictQueries } from '../queries'; import { usePredictDeposit } from './usePredictDeposit'; -import { PredictEventValues } from '../constants/eventNames'; +import { usePredictTrading } from './usePredictTrading'; interface UsePredictPlaceOrderOptions { /** @@ -42,6 +41,8 @@ interface UsePredictPlaceOrderReturn { placeOrder: (params: PlaceOrderParams) => Promise; isOrderNotFilled: boolean; resetOrderNotFilled: () => void; + showOrderPlacedToast: () => void; + invalidateOrderQueries: () => void; } export type PlaceOrderOutcome = @@ -151,6 +152,21 @@ export function usePredictPlaceOrder( }); }, [toastRef]); + const invalidateOrderQueries = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: predictQueries.balance.keys.all(), + }); + queryClient.invalidateQueries({ + queryKey: predictQueries.positions.keys.all(), + }); + queryClient.invalidateQueries({ + queryKey: predictQueries.activity.keys.all(), + }); + queryClient.invalidateQueries({ + queryKey: predictQueries.unrealizedPnL.keys.all(), + }); + }, [queryClient]); + const placeOrder = useCallback( async (orderParams: PlaceOrderParams): Promise => { const { @@ -218,21 +234,7 @@ export function usePredictPlaceOrder( setResult(orderResult); - queryClient.invalidateQueries({ - queryKey: predictQueries.balance.keys.all(), - }); - - queryClient.invalidateQueries({ - queryKey: predictQueries.positions.keys.all(), - }); - - queryClient.invalidateQueries({ - queryKey: predictQueries.activity.keys.all(), - }); - - queryClient.invalidateQueries({ - queryKey: predictQueries.unrealizedPnL.keys.all(), - }); + invalidateOrderQueries(); if (side === Side.BUY) { showOrderPlacedToast(); @@ -245,47 +247,15 @@ export function usePredictPlaceOrder( DevLogger.log('usePredictPlaceOrder: Order placed successfully'); return { status: 'success', result: orderResult }; } catch (err) { - const parsedErrorMessage = parseErrorMessage({ - error: err, - defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, - }); - DevLogger.log('usePredictPlaceOrder: Error placing order', { - error: parsedErrorMessage, - orderParams, - }); - - // Log error with order context (no sensitive data like amounts) - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictPlaceOrder', - }, - context: { - name: 'usePredictPlaceOrder', - data: { - method: 'placeOrder', - action: 'order_placement', - operation: 'order_management', - side: orderParams.preview?.side, - marketId: orderParams.analyticsProperties?.marketId, - transactionType: orderParams.analyticsProperties?.transactionType, - }, - }, - }); - - const rawMessage = err instanceof Error ? err.message : String(err); - const isNotFilled = - rawMessage === PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED || - rawMessage === PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED; - - if (isNotFilled) { + const errorResult = checkPlaceOrderError({ error: err, orderParams }); + if (errorResult.status === 'order_not_filled') { setIsOrderNotFilled(true); - return { status: 'order_not_filled' }; + } else if (errorResult.status === 'error') { + setError(errorResult.error); + onError?.(errorResult.error); } - setError(parsedErrorMessage); - onError?.(parsedErrorMessage); - return { status: 'error', error: parsedErrorMessage }; + return errorResult; } finally { setIsLoading(false); } @@ -298,7 +268,7 @@ export function usePredictPlaceOrder( toastRef, controllerPlaceOrder, onComplete, - queryClient, + invalidateOrderQueries, showOrderPlacedToast, showCashedOutToast, onError, @@ -317,5 +287,7 @@ export function usePredictPlaceOrder( placeOrder, isOrderNotFilled, resetOrderNotFilled, + showOrderPlacedToast, + invalidateOrderQueries, }; } diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx index da140bd3f1b..aefb02f599b 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx @@ -753,4 +753,171 @@ describe('usePredictToastRegistrations', () => { expect(showToast).not.toHaveBeenCalled(); }); }); + + describe('order transactions', () => { + it('shows prediction placed toast on confirmed status', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'confirmed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Icon', + iconName: 'Check', + hasNoTimeout: false, + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'balance'], + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'positions'], + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'activity'], + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); + }); + + it('shows prediction placed toast with View button when marketId is present', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'confirmed', + senderAddress: selectedAddress, + marketId: 'market-123', + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Icon', + iconName: 'Check', + linkButtonOptions: expect.objectContaining({ + label: 'predict.order.view', + onPress: expect.any(Function), + }), + }), + ); + + const onView = showToast.mock.calls[0][0].linkButtonOptions.onPress; + onView(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId: 'market-123' }, + }); + }); + + it('shows prediction placed toast without View button when marketId is absent', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'confirmed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.not.objectContaining({ + linkButtonOptions: expect.anything(), + }), + ); + }); + + it('shows error toast on failed status', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'failed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Icon', + iconName: 'Error', + hasNoTimeout: false, + }), + ); + }); + + it('shows error toast with Try Again button when marketId is present', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'failed', + senderAddress: selectedAddress, + marketId: 'market-456', + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + iconName: 'Error', + linkButtonOptions: expect.objectContaining({ + label: 'predict.order.try_again', + onPress: expect.any(Function), + }), + }), + ); + + const onRetry = showToast.mock.calls[0][0].linkButtonOptions.onPress; + onRetry(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId: 'market-456' }, + }); + }); + + it('shows error toast without Try Again button when marketId is absent', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'failed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.not.objectContaining({ + linkButtonOptions: expect.anything(), + }), + ); + }); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx index 8be10f0f751..428788d4d14 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx @@ -148,7 +148,7 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { const normalizedSelectedAddress = selectedAddress.toLowerCase(); const handleTransactionStatusChanged = useCallback( (payload: unknown, showToast: ToastRef['showToast']): void => { - const { type, status, senderAddress, transactionId, amount } = + const { type, status, senderAddress, transactionId, amount, marketId } = payload as PredictTransactionStatusChangedPayload; const canRetry = Boolean(senderAddress) && senderAddress === normalizedSelectedAddress; @@ -163,7 +163,7 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { }); // Deposit/Withdraw should not invalidate positions/activity - if (type === 'claim') { + if (type === 'claim' || type === 'order') { queryClient.invalidateQueries({ queryKey: predictQueries.positions.keys.all(), }); @@ -335,6 +335,59 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { return; } } + + if (type === 'order') { + if (status === 'confirmed') { + showToast({ + variant: ToastVariants.Icon, + iconName: IconName.Check, + iconColor: theme.colors.success.default, + labelOptions: [ + { + label: strings('predict.order.prediction_placed'), + isBold: true, + }, + ], + hasNoTimeout: false, + ...(marketId + ? { + linkButtonOptions: { + label: strings('predict.order.view'), + onPress: () => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId }, + }); + }, + }, + } + : {}), + }); + return; + } + + if (status === 'failed') { + showErrorToast({ + showToast, + title: strings('predict.order.prediction_failed'), + description: strings('predict.order.order_failed_generic'), + ...(marketId + ? { + retryLabel: strings('predict.order.try_again'), + onRetry: () => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId }, + }); + }, + } + : {}), + backgroundColor: theme.colors.accent04.normal, + iconColor: theme.colors.error.default, + }); + return; + } + } }, [ claim, diff --git a/app/components/UI/Predict/hooks/usePredictTrading.test.ts b/app/components/UI/Predict/hooks/usePredictTrading.test.ts index fc4d3eef623..ead7bf26598 100644 --- a/app/components/UI/Predict/hooks/usePredictTrading.test.ts +++ b/app/components/UI/Predict/hooks/usePredictTrading.test.ts @@ -18,6 +18,10 @@ jest.mock('../../../../core/Engine', () => ({ getBalance: jest.fn(), deposit: jest.fn(), payWithAnyTokenConfirmation: jest.fn(), + initPayWithAnyToken: jest.fn(), + previewOrder: jest.fn(), + prepareWithdraw: jest.fn(), + depositWithConfirmation: jest.fn(), }, }, })); @@ -227,39 +231,178 @@ describe('usePredictTrading', () => { }); }); - describe('payWithAnyTokenConfirmation', () => { - it('calls PredictController.payWithAnyTokenConfirmation and returns result', async () => { + describe('initPayWithAnyToken', () => { + it('calls PredictController.initPayWithAnyToken and returns result', async () => { const mockResult = { success: true, response: { batchId: 'batch-123' }, }; ( - Engine.context.PredictController - .payWithAnyTokenConfirmation as jest.Mock + Engine.context.PredictController.initPayWithAnyToken as jest.Mock ).mockResolvedValue(mockResult); const { result } = renderHook(() => usePredictTrading()); - const response = await result.current.payWithAnyTokenConfirmation(); + const response = await result.current.initPayWithAnyToken(); expect( - Engine.context.PredictController.payWithAnyTokenConfirmation, + Engine.context.PredictController.initPayWithAnyToken, ).toHaveBeenCalled(); expect(response).toEqual(mockResult); }); - it('throws error when PredictController.payWithAnyTokenConfirmation fails', async () => { - const mockError = new Error('Failed to pay with any token'); + it('throws error when PredictController.initPayWithAnyToken fails', async () => { + const mockError = new Error('Failed to initialize pay with any token'); ( - Engine.context.PredictController - .payWithAnyTokenConfirmation as jest.Mock + Engine.context.PredictController.initPayWithAnyToken as jest.Mock ).mockRejectedValue(mockError); const { result } = renderHook(() => usePredictTrading()); - await expect( - result.current.payWithAnyTokenConfirmation(), - ).rejects.toThrow('Failed to pay with any token'); + await expect(result.current.initPayWithAnyToken()).rejects.toThrow( + 'Failed to initialize pay with any token', + ); + }); + }); + + describe('previewOrder', () => { + it('calls PredictController.previewOrder and returns result', async () => { + const mockPreviewResult = { + marketId: 'market-1', + outcomeId: 'outcome-789', + outcomeTokenId: 'outcome-token-101', + timestamp: Date.now(), + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 100, + minAmountReceived: 180, + slippage: 0.01, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + }; + + ( + Engine.context.PredictController.previewOrder as jest.Mock + ).mockResolvedValue(mockPreviewResult); + + const { result } = renderHook(() => usePredictTrading()); + + const params = { + marketId: 'market-1', + outcomeId: 'outcome-789', + outcomeTokenId: 'outcome-token-101', + side: Side.BUY, + size: 100, + }; + + const response = await result.current.previewOrder(params); + + expect( + Engine.context.PredictController.previewOrder, + ).toHaveBeenCalledWith(params); + expect(response).toEqual(mockPreviewResult); + }); + + it('throws error when PredictController.previewOrder fails', async () => { + const mockError = new Error('Failed to preview order'); + ( + Engine.context.PredictController.previewOrder as jest.Mock + ).mockRejectedValue(mockError); + + const { result } = renderHook(() => usePredictTrading()); + + const params = { + marketId: 'market-1', + outcomeId: 'outcome-789', + outcomeTokenId: 'outcome-token-101', + side: Side.BUY, + size: 100, + }; + + await expect(result.current.previewOrder(params)).rejects.toThrow( + 'Failed to preview order', + ); + }); + }); + + describe('prepareWithdraw', () => { + it('calls PredictController.prepareWithdraw and returns result', async () => { + const mockWithdrawResult = { + txMeta: { id: 'tx-withdraw-123', hash: '0xwithdraw123' }, + success: true, + amount: 500, + }; + + ( + Engine.context.PredictController.prepareWithdraw as jest.Mock + ).mockResolvedValue(mockWithdrawResult); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + const response = await result.current.prepareWithdraw(params); + + expect( + Engine.context.PredictController.prepareWithdraw, + ).toHaveBeenCalledWith(params); + expect(response).toEqual(mockWithdrawResult); + }); + + it('throws error when PredictController.prepareWithdraw fails', async () => { + const mockError = new Error('Failed to prepare withdraw'); + ( + Engine.context.PredictController.prepareWithdraw as jest.Mock + ).mockRejectedValue(mockError); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + await expect(result.current.prepareWithdraw(params)).rejects.toThrow( + 'Failed to prepare withdraw', + ); + }); + }); + + describe('deposit', () => { + it('calls PredictController.depositWithConfirmation and returns result', async () => { + const mockDepositResult = { + txMeta: { id: 'tx-deposit-456', hash: '0xdeposit456' }, + success: true, + depositedAmount: 1000, + }; + + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockResolvedValue(mockDepositResult); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + const response = await result.current.deposit(params); + + expect( + Engine.context.PredictController.depositWithConfirmation, + ).toHaveBeenCalledWith(params); + expect(response).toEqual(mockDepositResult); + }); + + it('throws error when PredictController.depositWithConfirmation fails', async () => { + const mockError = new Error('Failed to deposit'); + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockRejectedValue(mockError); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + await expect(result.current.deposit(params)).rejects.toThrow( + 'Failed to deposit', + ); }); }); @@ -271,8 +414,9 @@ describe('usePredictTrading', () => { const initialClaim = result.current.claim; const initialGetBalance = result.current.getBalance; const initialPreviewOrder = result.current.previewOrder; - const initialPayWithAnyTokenConfirmation = - result.current.payWithAnyTokenConfirmation; + const initialPrepareWithdraw = result.current.prepareWithdraw; + const initialDeposit = result.current.deposit; + const initialInitPayWithAnyToken = result.current.initPayWithAnyToken; rerender({}); @@ -280,8 +424,10 @@ describe('usePredictTrading', () => { expect(result.current.claim).toBe(initialClaim); expect(result.current.getBalance).toBe(initialGetBalance); expect(result.current.previewOrder).toBe(initialPreviewOrder); - expect(result.current.payWithAnyTokenConfirmation).toBe( - initialPayWithAnyTokenConfirmation, + expect(result.current.prepareWithdraw).toBe(initialPrepareWithdraw); + expect(result.current.deposit).toBe(initialDeposit); + expect(result.current.initPayWithAnyToken).toBe( + initialInitPayWithAnyToken, ); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictTrading.ts b/app/components/UI/Predict/hooks/usePredictTrading.ts index 1da6041130f..fdf71e90ba0 100644 --- a/app/components/UI/Predict/hooks/usePredictTrading.ts +++ b/app/components/UI/Predict/hooks/usePredictTrading.ts @@ -44,9 +44,9 @@ export function usePredictTrading() { return controller.depositWithConfirmation(params); }, []); - const payWithAnyTokenConfirmation = useCallback(async () => { + const initPayWithAnyToken = useCallback(async () => { const controller = Engine.context.PredictController; - return controller.payWithAnyTokenConfirmation(); + return controller.initPayWithAnyToken(); }, []); return { @@ -56,6 +56,6 @@ export function usePredictTrading() { previewOrder, prepareWithdraw, deposit, - payWithAnyTokenConfirmation, + initPayWithAnyToken, }; } diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index d2b8711b15b..dac628237c1 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -254,6 +254,7 @@ describe('PolymarketProvider', () => { minimumVersion: '7.64.0', }, fakOrdersEnabled: false, + predictWithAnyTokenEnabled: false, }; const createProvider = ( featureFlagsOverride?: Partial, diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 32576547c90..f0b174b5ec3 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -2,6 +2,8 @@ import { selectPredictEnabledFlag, selectPredictFakOrdersEnabledFlag, selectPredictFeeCollectionFlag, + selectPredictGtmOnboardingModalEnabledFlag, + selectPredictHomeFeaturedVariant, selectPredictHotTabFlag, selectPredictWithAnyTokenEnabledFlag, } from '.'; @@ -926,39 +928,40 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictPayWithAnyTokenEnabledFlag', () => { - it('returns true when remote flag is enabled and version check passes', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); + describe('selectPredictWithAnyTokenEnabledFlag', () => { + it('returns false when remote flags are empty (version-gated default)', () => { + const result = selectPredictWithAnyTokenEnabledFlag( + mockedEmptyFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns false when controller is undefined', () => { const state = { engine: { backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictWithAnyToken: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, + RemoteFeatureFlagController: undefined, }, }, }; const result = selectPredictWithAnyTokenEnabledFlag(state); - expect(result).toBe(true); + expect(result).toBe(false); }); + }); - it('returns false when remote flag is disabled', () => { + describe('selectPredictGtmOnboardingModalEnabledFlag', () => { + it('returns version-gated flag value when remote flag is set', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { + const stateWithRemoteFlag = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: { - enabled: false, + predictGtmOnboardingModalEnabled: { + enabled: true, minimumVersion: '1.0.0', }, }, @@ -968,22 +971,20 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = + selectPredictGtmOnboardingModalEnabledFlag(stateWithRemoteFlag); - expect(result).toBe(false); + expect(result).toBe(true); }); - it('returns false when app version is below minimum required version', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - const state = { + it('returns false when env var not set and no remote flag', () => { + delete process.env.MM_PREDICT_GTM_MODAL_ENABLED; + const stateWithoutRemoteFlag = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: { - enabled: true, - minimumVersion: '99.0.0', - }, + predictGtmOnboardingModalEnabled: null, }, cacheTimestamp: 0, }, @@ -991,18 +992,33 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = selectPredictGtmOnboardingModalEnabledFlag( + stateWithoutRemoteFlag, + ); expect(result).toBe(false); }); + }); - it('defaults to false when remote flag is null', () => { - const state = { + describe('selectPredictHomeFeaturedVariant', () => { + it('returns carousel by default', () => { + const result = selectPredictHomeFeaturedVariant(mockedEmptyFlagsState); + + expect(result).toBe('carousel'); + }); + + it('returns list when remote flag variant is list and version check passes', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const stateWithListVariant = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: null, + predictHomeFeaturedVariant: { + enabled: true, + variant: 'list', + minimumVersion: '1.0.0', + }, }, cacheTimestamp: 0, }, @@ -1010,43 +1026,42 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); - - expect(result).toBe(false); - }); - - it('defaults to false when remote feature flags are empty', () => { - const result = selectPredictWithAnyTokenEnabledFlag( - mockedEmptyFlagsState, - ); + const result = selectPredictHomeFeaturedVariant(stateWithListVariant); - expect(result).toBe(false); + expect(result).toBe('list'); }); - it('defaults to false when controller is undefined', () => { - const state = { + it('returns carousel when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const stateWithHighMinVersion = { engine: { backgroundState: { - RemoteFeatureFlagController: undefined, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictHomeFeaturedVariant: { + enabled: true, + variant: 'list', + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, }, }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = selectPredictHomeFeaturedVariant(stateWithHighMinVersion); - expect(result).toBe(false); + expect(result).toBe('carousel'); }); - it('defaults to false when remote flag is invalid', () => { - const state = { + it('returns carousel when remote flag is null', () => { + const stateWithNullFlag = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: { - enabled: 'invalid', - minimumVersion: 123, - }, + predictHomeFeaturedVariant: null, }, cacheTimestamp: 0, }, @@ -1054,9 +1069,9 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = selectPredictHomeFeaturedVariant(stateWithNullFlag); - expect(result).toBe(false); + expect(result).toBe('carousel'); }); }); }); diff --git a/app/components/UI/Predict/selectors/predictController/index.test.ts b/app/components/UI/Predict/selectors/predictController/index.test.ts index 2546b6b2b48..5aff1526292 100644 --- a/app/components/UI/Predict/selectors/predictController/index.test.ts +++ b/app/components/UI/Predict/selectors/predictController/index.test.ts @@ -10,6 +10,7 @@ import { selectPredictAccountMeta, selectPredictAccountMetaByAddress, selectPredictWithdrawTransaction, + selectPredictActiveBuyOrder, selectPredictSelectedPaymentToken, } from './index'; import { PredictPosition, PredictPositionStatus } from '../../types'; @@ -140,6 +141,62 @@ describe('Predict Controller Selectors', () => { }); }); + describe('selectPredictActiveBuyOrder', () => { + it('returns active buy order when it exists', () => { + const activeBuyOrder = { + state: 'preview', + transactionId: 'tx-1', + }; + + const mockState = { + engine: { + backgroundState: { + PredictController: { + activeBuyOrder, + }, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectPredictActiveBuyOrder(mockState as any); + + expect(result).toEqual(activeBuyOrder); + }); + + it('returns null when active buy order is null', () => { + const mockState = { + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: null, + }, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectPredictActiveBuyOrder(mockState as any); + + expect(result).toBeNull(); + }); + + it('returns null when PredictController state is undefined', () => { + const mockState = { + engine: { + backgroundState: { + PredictController: undefined, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectPredictActiveBuyOrder(mockState as any); + + expect(result).toBeNull(); + }); + }); + describe('selectPredictClaimablePositions', () => { it('returns claimable positions when they exist', () => { const testAddress = '0x123'; diff --git a/app/components/UI/Predict/selectors/predictController/index.ts b/app/components/UI/Predict/selectors/predictController/index.ts index fe2c5770272..984511ee6a3 100644 --- a/app/components/UI/Predict/selectors/predictController/index.ts +++ b/app/components/UI/Predict/selectors/predictController/index.ts @@ -20,9 +20,9 @@ const selectPredictWithdrawTransaction = createSelector( (predictControllerState) => predictControllerState?.withdrawTransaction, ); -const selectPredictActiveOrder = createSelector( +const selectPredictActiveBuyOrder = createSelector( selectPredictControllerState, - (predictState) => predictState?.activeOrder ?? null, + (predictState) => predictState?.activeBuyOrder ?? null, ); const selectPredictClaimablePositions = createSelector( @@ -113,7 +113,7 @@ export { selectPredictPendingDeposits, selectPredictPendingClaims, selectPredictWithdrawTransaction, - selectPredictActiveOrder, + selectPredictActiveBuyOrder, selectPredictClaimablePositions, selectPredictClaimablePositionsByAddress, selectPredictWonPositions, diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 17e908799e3..3d43cfff0d5 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -23,6 +23,7 @@ export interface PredictFeatureFlags { liveSportsLeagues: string[]; marketHighlightsFlag: PredictMarketHighlightsFlag; fakOrdersEnabled: boolean; + predictWithAnyTokenEnabled: boolean; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 0bd8fab6710..c4cfd0dd16f 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -11,10 +11,10 @@ export type PredictOrderType = 'FOK' | 'FAK'; export enum ActiveOrderState { PREVIEW = 'preview', + PAY_WITH_ANY_TOKEN = 'pay_with_any_token', DEPOSITING = 'depositing', PLACING_ORDER = 'placing_order', - REDIRECTING = 'redirecting', - PAY_WITH_ANY_TOKEN = 'pay_with_any_token', + SUCCESS = 'success', } export enum PredictPriceHistoryInterval { @@ -493,6 +493,8 @@ export type OrderResult = Result<{ export interface PlaceOrderParams { preview: OrderPreview; + address?: string; + transactionId?: string; analyticsProperties?: { marketId?: string; marketTitle?: string; diff --git a/app/components/UI/Predict/types/navigation.ts b/app/components/UI/Predict/types/navigation.ts index 01fc08c1b56..c87cc8ca001 100644 --- a/app/components/UI/Predict/types/navigation.ts +++ b/app/components/UI/Predict/types/navigation.ts @@ -4,7 +4,6 @@ import { ParamListBase } from '@react-navigation/native'; import { - OrderPreview, PredictActivityItem, PredictCategory, PredictMarket, @@ -56,11 +55,6 @@ export interface PredictBuyPreviewParams { outcome: PredictOutcome; outcomeToken: PredictOutcomeToken; entryPoint?: PredictEntryPoint; - batchId?: string; - animationEnabled?: boolean; - isConfirmation?: boolean; - isConfirming?: boolean; - preview?: OrderPreview; } /** Predict sell preview parameters */ diff --git a/app/components/UI/Predict/utils/predictErrorHandler.test.ts b/app/components/UI/Predict/utils/predictErrorHandler.test.ts index 530e72ecfd5..57ea1253b4a 100644 --- a/app/components/UI/Predict/utils/predictErrorHandler.test.ts +++ b/app/components/UI/Predict/utils/predictErrorHandler.test.ts @@ -1,11 +1,15 @@ import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; import { PREDICT_ERROR_CODES } from '../constants/errors'; +import { Side } from '../types'; import { ensureError, createDepositErrorToast, parseErrorMessage, + checkPlaceOrderError, } from './predictErrorHandler'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../util/Logger'; jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -17,9 +21,21 @@ jest.mock('../constants/errors', () => ({ PREDICT_NOT_ELIGIBLE: 'You are not eligible', PREDICT_PLACE_ORDER_FAILED: 'Order placement failed', PREDICT_UNKNOWN_ERROR: 'Something went wrong', + PREDICT_BUY_ORDER_NOT_FULLY_FILLED: 'Buy order not fully filled', + PREDICT_SELL_ORDER_NOT_FULLY_FILLED: 'Sell order not fully filled', }), })); +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + __esModule: true, + default: { log: jest.fn() }, +})); + +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + const mockTheme = { colors: { error: { default: 'error-color' }, @@ -167,4 +183,207 @@ describe('predictErrorHandler', () => { expect(result).toBe('Order placement failed'); }); }); + + describe('checkPlaceOrderError', () => { + const mockDevLogger = jest.mocked(DevLogger); + const mockLogger = jest.mocked(Logger); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createMockOrderParams = () => ({ + preview: { + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + timestamp: 1234567890, + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 100, + minAmountReceived: 50, + slippage: 0.01, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + }, + analyticsProperties: { + marketId: 'market-1', + transactionType: 'buy', + }, + }); + + it('returns order_not_filled status when error message is BUY_ORDER_NOT_FULLY_FILLED', () => { + const error = new Error(PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('returns order_not_filled status when error message is SELL_ORDER_NOT_FULLY_FILLED', () => { + const error = new Error(PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('returns error status with parsed message for generic Error', () => { + const error = new Error('Some generic error'); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + }); + + it('returns error status with parsed message for string error', () => { + const error = 'String error message'; + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + }); + + it('calls Logger.error with wrapped error and context', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + + checkPlaceOrderError({ error, orderParams }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'Predict', + component: 'usePredictPlaceOrder', + }, + context: expect.objectContaining({ + name: 'usePredictPlaceOrder', + data: expect.objectContaining({ + method: 'placeOrder', + action: 'order_placement', + operation: 'order_management', + side: 'BUY', + marketId: 'market-1', + transactionType: 'buy', + }), + }), + }), + ); + }); + + it('calls DevLogger.log with parsed error message and order params', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + + checkPlaceOrderError({ error, orderParams }); + + expect(mockDevLogger.log).toHaveBeenCalledWith( + 'usePredictPlaceOrder: Error placing order', + expect.objectContaining({ + error: 'Order placement failed', + orderParams, + }), + ); + }); + + it('returns error status with mapped message when error code is known', () => { + const error = new Error(PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + }); + + it('handles string error that matches BUY_ORDER_NOT_FULLY_FILLED', () => { + const error = PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED; + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('handles string error that matches SELL_ORDER_NOT_FULLY_FILLED', () => { + const error = PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED; + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('includes side from preview in Logger context', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + orderParams.preview.side = Side.SELL; + + checkPlaceOrderError({ error, orderParams }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + side: Side.SELL, + }), + }), + }), + ); + }); + + it('includes marketId from analyticsProperties in Logger context', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + orderParams.analyticsProperties = { + marketId: 'custom-market-id', + transactionType: 'buy', + }; + + checkPlaceOrderError({ error, orderParams }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + marketId: 'custom-market-id', + }), + }), + }), + ); + }); + + it('handles error with minimal analyticsProperties', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + orderParams.analyticsProperties = { + marketId: 'market-1', + transactionType: 'buy', + }; + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Predict/utils/predictErrorHandler.ts b/app/components/UI/Predict/utils/predictErrorHandler.ts index 54c491d2151..b6a3fe52bd2 100644 --- a/app/components/UI/Predict/utils/predictErrorHandler.ts +++ b/app/components/UI/Predict/utils/predictErrorHandler.ts @@ -1,7 +1,15 @@ import { strings } from '../../../../../locales/i18n'; import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; -import { getPredictErrorMessages } from '../constants/errors'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../util/Logger'; +import { + getPredictErrorMessages, + PREDICT_CONSTANTS, + PREDICT_ERROR_CODES, +} from '../constants/errors'; +import { PlaceOrderOutcome } from '../hooks/usePredictPlaceOrder'; +import { PlaceOrderParams } from '../types'; /** * Ensures we have a proper Error object for logging @@ -62,3 +70,71 @@ export function parseErrorMessage({ } return errorMessage; } + +interface PlaceOrderErrorParams { + error: unknown; + orderParams: PlaceOrderParams; +} + +export const getPlaceOrderErrorOutcome = ({ + error: placeOrderError, +}: PlaceOrderErrorParams): PlaceOrderOutcome => { + const parsedErrorMessage = parseErrorMessage({ + error: placeOrderError, + defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }); + + const rawMessage = + placeOrderError instanceof Error + ? placeOrderError.message + : String(placeOrderError); + const isNotFilled = + rawMessage === PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED || + rawMessage === PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED; + + if (isNotFilled) { + return { status: 'order_not_filled' }; + } + + return { status: 'error', error: parsedErrorMessage }; +}; + +export const logPlaceOrderError = ({ + error: placeOrderError, + orderParams, +}: PlaceOrderErrorParams): void => { + const parsedErrorMessage = parseErrorMessage({ + error: placeOrderError, + defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }); + DevLogger.log('usePredictPlaceOrder: Error placing order', { + error: parsedErrorMessage, + orderParams, + }); + + // Log error with order context (no sensitive data like amounts) + Logger.error(ensureError(placeOrderError), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'usePredictPlaceOrder', + }, + context: { + name: 'usePredictPlaceOrder', + data: { + method: 'placeOrder', + action: 'order_placement', + operation: 'order_management', + side: orderParams.preview?.side, + marketId: orderParams.analyticsProperties?.marketId, + transactionType: orderParams.analyticsProperties?.transactionType, + }, + }, + }); +}; + +export const checkPlaceOrderError = ( + params: PlaceOrderErrorParams, +): PlaceOrderOutcome => { + logPlaceOrderError(params); + return getPlaceOrderErrorOutcome(params); +}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx new file mode 100644 index 00000000000..612b37dd9c0 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx @@ -0,0 +1,410 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import PredictBuyWithAnyToken from './PredictBuyWithAnyToken'; + +const mockHandleConfirm = jest.fn(); +const mockPlaceOrder = jest.fn(); +const mockShowOrderPlacedToast = jest.fn(); +const mockInvalidateOrderQueries = jest.fn(); +const mockResetOrderNotFilled = jest.fn(); +const mockSetCurrentValue = jest.fn(); +const mockSetCurrentValueUSDString = jest.fn(); +const mockSetIsInputFocused = jest.fn(); +const mockSetIsUserInputChange = jest.fn(); +const mockSetIsConfirming = jest.fn(); +const mockHandleRetryWithBestPrice = jest.fn(); + +let mockPayWithAnyTokenEnabled = true; +let mockFakOrdersEnabled = false; +let mockIsPreviewCalculating = false; +let mockIsPlacingOrder = false; +let mockCanSelectToken = true; +let mockErrorMessage: string | undefined; + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: jest.fn(() => ({})), + }), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useRoute: () => ({ + params: { + market: { id: 'market-1' }, + outcome: { id: 'outcome-1' }, + outcomeToken: { id: 'token-1', title: 'Yes', price: 0.62 }, + entryPoint: 'market_details', + }, + }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../selectors/featureFlags', () => ({ + selectPredictWithAnyTokenEnabledFlag: jest.fn( + () => mockPayWithAnyTokenEnabled, + ), + selectPredictFakOrdersEnabledFlag: jest.fn(() => mockFakOrdersEnabled), +})); + +jest.mock('../../utils/analytics', () => ({ + parseAnalyticsProperties: jest.fn(() => ({ + marketId: 'market-1', + sharePrice: 0.62, + })), +})); + +jest.mock('../../utils/format', () => ({ + formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), +})); + +jest.mock('../../hooks/usePredictActiveOrder', () => ({ + usePredictActiveOrder: () => ({ + isPlacingOrder: mockIsPlacingOrder, + }), +})); + +jest.mock('../../hooks/usePredictMeasurement', () => ({ + usePredictMeasurement: jest.fn(), +})); + +jest.mock('../../hooks/usePredictOrderPreview', () => ({ + usePredictOrderPreview: () => ({ + preview: { + sharePrice: 0.62, + minAmountReceived: 24, + }, + error: null, + isCalculating: mockIsPreviewCalculating, + }), +})); + +jest.mock('../../hooks/usePredictOrderRetry', () => ({ + usePredictOrderRetry: () => ({ + retrySheetRef: { current: null }, + retrySheetVariant: 'busy', + isRetrying: false, + handleRetryWithBestPrice: mockHandleRetryWithBestPrice, + }), +})); + +jest.mock('../../hooks/usePredictPlaceOrder', () => ({ + usePredictPlaceOrder: () => ({ + showOrderPlacedToast: mockShowOrderPlacedToast, + invalidateOrderQueries: mockInvalidateOrderQueries, + }), +})); + +jest.mock('./hooks/usePredictBuyAvailableBalance', () => ({ + usePredictBuyAvailableBalance: () => ({ + availableBalance: 10, + isBalanceLoading: false, + }), +})); + +jest.mock('./hooks/usePredictBuyInputState', () => ({ + usePredictBuyInputState: () => ({ + currentValue: 20, + setCurrentValue: mockSetCurrentValue, + currentValueUSDString: '$20.00', + setCurrentValueUSDString: mockSetCurrentValueUSDString, + isInputFocused: false, + setIsInputFocused: mockSetIsInputFocused, + isUserInputChange: true, + setIsUserInputChange: mockSetIsUserInputChange, + isConfirming: false, + setIsConfirming: mockSetIsConfirming, + }), +})); + +jest.mock('./hooks/usePredictBuyInfo', () => ({ + usePredictBuyInfo: () => ({ + toWin: 24, + metamaskFee: 1, + providerFee: 2, + total: 23, + depositFee: 3, + depositAmount: 4, + rewardsFeeAmount: 5, + totalPayForPredictBalance: 20, + }), +})); + +jest.mock('./hooks/usePredictBuyConditions', () => ({ + usePredictBuyConditions: () => ({ + canPlaceBet: true, + isUserChangeTriggeringCalculation: false, + isPayFeesLoading: false, + isBalancePulsing: false, + isBelowMinimum: false, + isInsufficientBalance: false, + maxBetAmount: 50, + canSelectToken: mockCanSelectToken, + }), +})); + +jest.mock('./hooks/usePredictBuyError', () => ({ + usePredictBuyError: () => ({ + errorMessage: mockErrorMessage, + isOrderNotFilled: false, + resetOrderNotFilled: mockResetOrderNotFilled, + }), +})); + +jest.mock('./hooks/usePredictBuyActions', () => ({ + usePredictBuyActions: () => ({ + handleConfirm: mockHandleConfirm, + placeOrder: mockPlaceOrder, + }), +})); + +jest.mock( + './components/PredictBuyPreviewHeader/PredictBuyPreviewHeader', + () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictBuyPreviewHeader() { + return Header; + }; + }, +); + +jest.mock('./components/PredictBuyAmountSection', () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictBuyAmountSection({ + availableBalanceDisplay, + isPlacingOrder, + }: { + availableBalanceDisplay: string; + isPlacingOrder: boolean; + }) { + return ( + + {`Amount Section ${availableBalanceDisplay} placing-${String( + isPlacingOrder, + )}`} + + ); + }; +}); + +jest.mock('./components/PredictBuyBottomContent', () => { + const { View } = jest.requireActual('react-native'); + return function MockPredictBuyBottomContent({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; +}); + +jest.mock('./components/PredictBuyError', () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictBuyError({ + errorMessage, + }: { + errorMessage?: string; + }) { + return {errorMessage ?? 'no-error'}; + }; +}); + +jest.mock('../../components/PredictFeeBreakdownSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return ReactActual.forwardRef( + ( + { + onClose, + fakOrdersEnabled, + }: { + onClose: () => void; + fakOrdersEnabled: boolean; + }, + _ref: unknown, + ) => ( + + {`fak-orders-${String(fakOrdersEnabled)}`} + + Close Fee Breakdown + + + ), + ); +}); + +jest.mock('../../components/PredictKeypad', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.forwardRef((_props: unknown, _ref: unknown) => ( + + )); +}); + +jest.mock('../../components/PredictOrderRetrySheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.forwardRef((_props: unknown, _ref: unknown) => ( + + )); +}); + +jest.mock('./components/PredictPayWithAnyTokenInfo', () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictPayWithAnyTokenInfo({ + depositAmount, + }: { + depositAmount: number; + }) { + return ( + {depositAmount} + ); + }; +}); + +jest.mock('./components/PredictPayWithRow', () => { + const { Text } = jest.requireActual('react-native'); + return { + PredictPayWithRow: ({ disabled }: { disabled?: boolean }) => ( + {`disabled-${String(disabled)}`} + ), + }; +}); + +jest.mock('./components/PredictFeeSummary/PredictFeeSummary', () => { + const { Pressable, Text } = jest.requireActual('react-native'); + return function MockPredictFeeSummary({ + handleFeesInfoPress, + }: { + handleFeesInfoPress: () => void; + }) { + return ( + + Fee Summary + + ); + }; +}); + +jest.mock('./components/PredictBuyActionButton', () => { + const { Pressable, Text } = jest.requireActual('react-native'); + return function MockPredictBuyActionButton({ + onPress, + disabled, + }: { + onPress: () => void; + disabled: boolean; + }) { + return ( + + {`button-disabled-${String(disabled)}`} + + ); + }; +}); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('PredictBuyWithAnyToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPayWithAnyTokenEnabled = true; + mockFakOrdersEnabled = false; + mockIsPreviewCalculating = false; + mockIsPlacingOrder = false; + mockCanSelectToken = true; + mockErrorMessage = undefined; + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + RemoteFeatureFlagController: {}, + }, + }, + }); + } + + return undefined; + }); + }); + + it('renders the screen, resets user input change after preview calculation, and opens the fee breakdown sheet', () => { + renderWithProvider(); + + expect(screen.getByTestId('predict-buy-preview-header')).toBeOnTheScreen(); + expect(screen.getByTestId('predict-buy-amount-section')).toHaveTextContent( + 'Amount Section $10.00 placing-false', + ); + expect(screen.getByTestId('predict-pay-with-row')).toHaveTextContent( + 'disabled-false', + ); + expect(mockSetIsUserInputChange).toHaveBeenCalledWith(false); + + fireEvent.press(screen.getByTestId('predict-fee-summary')); + + expect(screen.getByTestId('predict-fee-breakdown-sheet')).toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId('close-fee-breakdown')); + + expect( + screen.queryByTestId('predict-fee-breakdown-sheet'), + ).not.toBeOnTheScreen(); + }); + + it('hides the pay with row when the feature flag is disabled', () => { + mockPayWithAnyTokenEnabled = false; + + renderWithProvider(); + + expect(screen.queryByTestId('predict-pay-with-row')).not.toBeOnTheScreen(); + }); + + it('disables the pay with row when token selection is unavailable and forwards the confirm action', () => { + mockCanSelectToken = false; + mockErrorMessage = 'Insufficient balance'; + + renderWithProvider(); + + expect(screen.getByTestId('predict-pay-with-row')).toHaveTextContent( + 'disabled-true', + ); + expect(screen.getByTestId('predict-buy-error')).toHaveTextContent( + 'Insufficient balance', + ); + expect( + screen.getByTestId('predict-pay-with-any-token-info'), + ).toHaveTextContent('4'); + + fireEvent.press(screen.getByTestId('predict-buy-action-button')); + + expect(mockHandleConfirm).toHaveBeenCalledTimes(1); + }); + + it('does not reset user input change while preview calculation is still running', () => { + mockIsPreviewCalculating = true; + + renderWithProvider(); + + expect(mockSetIsUserInputChange).not.toHaveBeenCalled(); + }); + + it('disables token selection while an order is being placed', () => { + mockIsPlacingOrder = true; + + renderWithProvider(); + + expect(screen.getByTestId('predict-buy-amount-section')).toHaveTextContent( + 'Amount Section $10.00 placing-true', + ); + expect(screen.getByTestId('predict-pay-with-row')).toHaveTextContent( + 'disabled-true', + ); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index a752c80ee1e..1d817c461cc 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -14,7 +14,7 @@ import React, { useState, } from 'react'; import { ScrollView } from 'react-native'; -import { Edge, SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import { TraceName } from '../../../../../util/trace'; @@ -22,7 +22,7 @@ import { PredictBuyPreviewSelectorsIDs } from '../../Predict.testIds'; import PredictBuyActionButton from './components/PredictBuyActionButton'; import PredictBuyAmountSection from './components/PredictBuyAmountSection'; import PredictBuyBottomContent from './components/PredictBuyBottomContent'; -import PredictBuyMinimumError from './components/PredictBuyMinimumError'; +import PredictBuyError from './components/PredictBuyError'; import PredictBuyPreviewHeader from './components/PredictBuyPreviewHeader/PredictBuyPreviewHeader'; import PredictFeeBreakdownSheet from '../../components/PredictFeeBreakdownSheet'; import PredictFeeSummary from './components/PredictFeeSummary/PredictFeeSummary'; @@ -33,24 +33,24 @@ import PredictOrderRetrySheet from '../../components/PredictOrderRetrySheet'; import PredictPayWithAnyTokenInfo from './components/PredictPayWithAnyTokenInfo'; import { PredictPayWithRow } from './components/PredictPayWithRow'; import { usePredictBuyAvailableBalance } from './hooks/usePredictBuyAvailableBalance'; -import usePredictBuyBackSwipe from './hooks/usePredictBuyBackSwipe'; import { usePredictBuyConditions } from './hooks/usePredictBuyConditions'; import { usePredictBuyInfo } from './hooks/usePredictBuyInfo'; import { usePredictBuyInputState } from './hooks/usePredictBuyInputState'; -import { usePredictBuyActions } from './hooks/usePredictBuyPreviewActions'; +import { usePredictBuyActions } from './hooks/usePredictBuyActions'; import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview'; import { usePredictOrderRetry } from '../../hooks/usePredictOrderRetry'; -import { usePredictPayWithAnyTokenTracking } from './hooks/usePredictPayWithAnyTokenTracking'; -import { usePredictPaymentToken } from '../../hooks/usePredictPaymentToken'; import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder'; -import { selectPredictFakOrdersEnabledFlag } from '../../selectors/featureFlags'; +import { + selectPredictFakOrdersEnabledFlag, + selectPredictWithAnyTokenEnabledFlag, +} from '../../selectors/featureFlags'; import { Side } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { parseAnalyticsProperties } from '../../utils/analytics'; -import { usePredictOrderTracking } from './hooks/usePredictOrderTracking'; - -const SHOW_TOKEN_SELECTION = false; +import { formatPrice } from '../../utils/format'; +import { usePredictBuyError } from './hooks/usePredictBuyError'; +import { usePredictActiveOrder } from '../../hooks/usePredictActiveOrder'; const PredictBuyWithAnyToken = () => { const tw = useTailwind(); @@ -59,17 +59,19 @@ const PredictBuyWithAnyToken = () => { const route = useRoute>(); - const { - market, - outcome, - outcomeToken, - entryPoint, - isConfirmation, - preview: initialPreview, - } = route.params; + const { market, outcome, outcomeToken, entryPoint } = route.params; + + const { isPlacingOrder } = usePredictActiveOrder(); + const { showOrderPlacedToast, invalidateOrderQueries } = + usePredictPlaceOrder(); const [isFeeBreakdownVisible, setIsFeeBreakdownVisible] = useState(false); + const payWithAnyTokenEnabled = useSelector( + selectPredictWithAnyTokenEnabledFlag, + ); + const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag); + const analyticsProperties = useMemo( () => parseAnalyticsProperties(market, outcomeToken, entryPoint), [market, outcomeToken, entryPoint], @@ -78,6 +80,15 @@ const PredictBuyWithAnyToken = () => { const { availableBalance, isBalanceLoading } = usePredictBuyAvailableBalance(); + const availableBalanceDisplay = useMemo( + () => + formatPrice(availableBalance, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + [availableBalance], + ); + const { currentValue, setCurrentValue, @@ -91,15 +102,6 @@ const PredictBuyWithAnyToken = () => { setIsConfirming, } = usePredictBuyInputState(); - const { - placeOrder, - isLoading: isPlaceOrderLoading, - error: placeOrderError, - result, - isOrderNotFilled, - resetOrderNotFilled, - } = usePredictPlaceOrder(); - const handleFeesInfoPress = useCallback(() => { setIsFeeBreakdownVisible(true); }, []); @@ -108,8 +110,6 @@ const PredictBuyWithAnyToken = () => { setIsFeeBreakdownVisible(false); }, []); - const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag); - const { preview, error: previewError, @@ -121,7 +121,6 @@ const PredictBuyWithAnyToken = () => { side: Side.BUY, size: currentValue, autoRefreshTimeout: 1000, - initialPreview, }); const { @@ -130,60 +129,54 @@ const PredictBuyWithAnyToken = () => { providerFee, total, depositFee, + depositAmount, rewardsFeeAmount, - errorMessage, + totalPayForPredictBalance, } = usePredictBuyInfo({ currentValue, preview, previewError, - isPlaceOrderLoading, - placeOrderError, - isOrderNotFilled, isConfirming, + isPlacingOrder, }); const { - handleBack, - handleBackSwipe, - handleTokenSelected, - handleConfirm, - handleDepositFailed, - handlePlaceOrderSuccess, - handlePlaceOrderError, - } = usePredictBuyActions({ - currentValue, - analyticsProperties, - preview, - placeOrder, - depositAmount: total - depositFee, - setIsConfirming, - }); - - usePredictBuyBackSwipe({ onBack: handleBackSwipe }); - - usePredictPayWithAnyTokenTracking({ - onFail: handleDepositFailed, - onConfirm: handleConfirm, - }); - - const { - isPlacingOrder, - isBelowMinimum, canPlaceBet, isUserChangeTriggeringCalculation, isPayFeesLoading, isBalancePulsing, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + canSelectToken, } = usePredictBuyConditions({ currentValue, preview, isPreviewCalculating, - isPlaceOrderLoading, isUserInputChange, isConfirming, + totalPayForPredictBalance, + isInputFocused, }); - usePredictPaymentToken({ - onTokenSelected: handleTokenSelected, + const { errorMessage, isOrderNotFilled, resetOrderNotFilled } = + usePredictBuyError({ + preview, + previewError, + isPlacingOrder, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + isConfirming, + isPayFeesLoading, + }); + + const { handleConfirm, placeOrder } = usePredictBuyActions({ + analyticsProperties, + preview, + setIsConfirming, + showOrderPlacedToast, + invalidateOrderQueries, }); useEffect(() => { @@ -216,28 +209,13 @@ const PredictBuyWithAnyToken = () => { }, }); - usePredictOrderTracking({ - result, - error: placeOrderError, - onSuccess: handlePlaceOrderSuccess, - onError: handlePlaceOrderError, - }); - - const edges = useMemo( - () => (isConfirmation ? (['top', 'left', 'right'] as Edge[]) : undefined), - [isConfirmation], - ); - return ( - + { isInputFocused={isInputFocused} isBalanceLoading={isBalanceLoading} isBalancePulsing={isBalancePulsing} - availableBalanceDisplay={availableBalance} + availableBalanceDisplay={availableBalanceDisplay} toWin={toWin} isShowingToWinSkeleton={isUserChangeTriggeringCalculation} + isPlacingOrder={isPlacingOrder} /> - {SHOW_TOKEN_SELECTION && ( - + {payWithAnyTokenEnabled && ( + )} - + { setCurrentValueUSDString={setCurrentValueUSDString} setIsInputFocused={setIsInputFocused} /> - + { onDismiss={resetOrderNotFilled} isRetrying={isRetrying} /> - {isConfirmation && ( - - )} + ); }; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx index d813b162b28..94d960a30f0 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; import PredictBuyAmountSection from './PredictBuyAmountSection'; import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; @@ -20,12 +20,24 @@ jest.mock('../../../../utils/format', () => ({ })); jest.mock('../../../../components/PredictAmountDisplay', () => { - const { View: RNView, Text: RNText } = jest.requireActual('react-native'); + const { + Pressable: RNPressable, + View: RNView, + Text: RNText, + } = jest.requireActual('react-native'); return function MockPredictAmountDisplay(props: Record) { return ( - - {props.amount as string} - + void} + > + + {props.amount as string} + + {String(props.isActive)} + + + ); }; }); @@ -65,6 +77,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -82,6 +95,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -100,6 +114,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$1,234.56" toWin={250} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -119,6 +134,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton + isPlacingOrder={false} />, ); @@ -136,6 +152,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={150} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -153,6 +170,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -172,6 +190,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={250} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -189,11 +208,34 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); const amountDisplay = screen.getByTestId('amount-display'); - expect(amountDisplay).toBeOnTheScreen(); + fireEvent.press(amountDisplay); + + expect(mockKeypadRef.current.handleAmountPress).toHaveBeenCalledTimes(1); + }); + + it('marks the amount display as active when focused and not placing an order', () => { + renderWithProvider( + , + ); + + expect(screen.getByTestId('amount-display-active')).toHaveTextContent( + 'true', + ); }); }); @@ -209,6 +251,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -226,6 +269,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -245,6 +289,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={0} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -262,6 +307,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$50000" toWin={10000} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -279,6 +325,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -296,6 +343,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -313,6 +361,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton + isPlacingOrder={false} />, ); @@ -321,6 +370,31 @@ describe('PredictBuyAmountSection', () => { }); }); + describe('isPlacingOrder behavior', () => { + it('disables amount press and isActive when isPlacingOrder is true', () => { + renderWithProvider( + , + ); + + fireEvent.press(screen.getByTestId('amount-display')); + + expect(mockKeypadRef.current.handleAmountPress).not.toHaveBeenCalled(); + expect(screen.getByTestId('amount-display-active')).toHaveTextContent( + 'false', + ); + }); + }); + describe('integration', () => { it('displays all sections together', () => { renderWithProvider( @@ -333,6 +407,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={150} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx index 1e0f0a6f32e..521540ea5f4 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx @@ -25,6 +25,7 @@ interface PredictBuyAmountSectionProps { availableBalanceDisplay: string; toWin: number; isShowingToWinSkeleton: boolean; + isPlacingOrder: boolean; } const PredictBuyAmountSection = ({ @@ -36,6 +37,7 @@ const PredictBuyAmountSection = ({ availableBalanceDisplay, toWin, isShowingToWinSkeleton, + isPlacingOrder, }: PredictBuyAmountSectionProps) => { const tw = useTailwind(); const pulseAnim = useRef(new Animated.Value(1)).current; @@ -68,8 +70,10 @@ const PredictBuyAmountSection = ({ keypadRef.current?.handleAmountPress()} - isActive={isInputFocused} + onPress={() => + !isPlacingOrder && keypadRef.current?.handleAmountPress() + } + isActive={isInputFocused && !isPlacingOrder} hasError={false} /> diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx index ea0670f1819..ca84d50fcf9 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx @@ -37,7 +37,7 @@ describe('PredictBuyBottomContent', () => { describe('when isInputFocused is true', () => { it('returns null and does not render anything', () => { renderWithProvider( - + {mockChildren} , ); @@ -45,26 +45,12 @@ describe('PredictBuyBottomContent', () => { expect(screen.queryByText(/Disclaimer text/)).not.toBeOnTheScreen(); expect(screen.queryByTestId('children-content')).not.toBeOnTheScreen(); }); - - it('returns null even when errorMessage is provided', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.queryByText(/Error message/)).not.toBeOnTheScreen(); - expect(screen.queryByText(/Disclaimer text/)).not.toBeOnTheScreen(); - }); }); describe('when isInputFocused is false', () => { it('renders children content', () => { renderWithProvider( - + {mockChildren} , ); @@ -74,10 +60,7 @@ describe('PredictBuyBottomContent', () => { it('renders disclaimer text', () => { renderWithProvider( - + {mockChildren} , ); @@ -87,10 +70,7 @@ describe('PredictBuyBottomContent', () => { it('renders learn more link', () => { renderWithProvider( - + {mockChildren} , ); @@ -100,10 +80,7 @@ describe('PredictBuyBottomContent', () => { it('opens Polymarket TOS URL when learn more is pressed', () => { renderWithProvider( - + {mockChildren} , ); @@ -117,61 +94,7 @@ describe('PredictBuyBottomContent', () => { }); }); - describe('error message display', () => { - it('displays error message when provided', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByText(/Insufficient balance/)).toBeOnTheScreen(); - }); - - it('does not display error message when not provided', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.queryByText(/Insufficient balance/)).not.toBeOnTheScreen(); - }); - - it('displays error message along with children and disclaimer', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByText(/Network error/)).toBeOnTheScreen(); - expect(screen.getByTestId('children-content')).toBeOnTheScreen(); - expect(screen.getByText(/Disclaimer text/)).toBeOnTheScreen(); - }); - }); - describe('edge cases', () => { - it('handles empty error message string', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByTestId('children-content')).toBeOnTheScreen(); - expect(screen.getByText(/Disclaimer text/)).toBeOnTheScreen(); - }); - it('handles multiple children elements', () => { const multipleChildren = ( <> @@ -185,10 +108,7 @@ describe('PredictBuyBottomContent', () => { ); renderWithProvider( - + {multipleChildren} , ); @@ -196,31 +116,12 @@ describe('PredictBuyBottomContent', () => { expect(screen.getByTestId('child-1')).toBeOnTheScreen(); expect(screen.getByTestId('child-2')).toBeOnTheScreen(); }); - - it('handles long error message text', () => { - const longError = - 'This is a very long error message that explains in detail what went wrong with the transaction'; - - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByText(new RegExp(longError))).toBeOnTheScreen(); - }); }); describe('Linking behavior', () => { it('calls Linking.openURL with correct URL', () => { renderWithProvider( - + {mockChildren} , ); @@ -236,10 +137,7 @@ describe('PredictBuyBottomContent', () => { it('opens URL only when learn more is pressed', () => { renderWithProvider( - + {mockChildren} , ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx index a743fee508c..8d90ccc7f02 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx @@ -13,13 +13,11 @@ import { strings } from '../../../../../../../../locales/i18n'; interface PredictBuyBottomContentProps { isInputFocused: boolean; - errorMessage?: string; children: React.ReactNode; } const PredictBuyBottomContent = ({ isInputFocused, - errorMessage, children, }: PredictBuyBottomContentProps) => { const tw = useTailwind(); @@ -34,15 +32,6 @@ const PredictBuyBottomContent = ({ twClassName="border-t border-muted px-4 pb-0" > - {errorMessage && ( - - {errorMessage} - - )} {children} diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx new file mode 100644 index 00000000000..63e1b1238ca --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import PredictBuyError from './PredictBuyError'; +import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; + +describe('PredictBuyError', () => { + describe('when errorMessage is undefined', () => { + it('renders nothing', () => { + renderWithProvider(); + + expect(screen.queryByText(/./)).not.toBeOnTheScreen(); + }); + }); + + describe('when errorMessage is empty string', () => { + it('renders nothing', () => { + renderWithProvider(); + + expect(screen.queryByText(/./)).not.toBeOnTheScreen(); + }); + }); + + describe('when errorMessage is provided', () => { + it('displays the error message text', () => { + const errorMessage = 'Insufficient balance'; + + renderWithProvider(); + + expect(screen.getByText(errorMessage)).toBeOnTheScreen(); + }); + + it('renders error text with centered alignment and error color', () => { + const errorMessage = 'Minimum bet required'; + + renderWithProvider(); + + const errorText = screen.getByText(errorMessage); + expect(errorText).toBeOnTheScreen(); + expect(errorText.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ textAlign: 'center' }), + ]), + ); + }); + }); + + describe('when no props provided', () => { + it('renders nothing', () => { + renderWithProvider(); + + expect(screen.queryByText(/./)).not.toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx new file mode 100644 index 00000000000..f23644b1a58 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { + Box, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +interface PredictBuyErrorProps { + errorMessage?: string; +} + +const PredictBuyError = ({ errorMessage }: PredictBuyErrorProps) => { + const tw = useTailwind(); + + if (!errorMessage) return null; + + return ( + + + {errorMessage} + + + ); +}; + +export default PredictBuyError; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts new file mode 100644 index 00000000000..7f900bfa2da --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts @@ -0,0 +1 @@ +export { default } from './PredictBuyError'; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx deleted file mode 100644 index e5bf94cec96..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import PredictBuyMinimumError from './PredictBuyMinimumError'; -import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; - -jest.mock('../../../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, options?: Record) => { - if (key === 'predict.order.prediction_minimum_bet') { - return `Minimum bet: ${options?.amount}`; - } - return key; - }), -})); - -jest.mock('../../../../utils/format', () => ({ - formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), -})); - -jest.mock('../../../../constants/transactions', () => ({ - MINIMUM_BET: 1, -})); - -describe('PredictBuyMinimumError', () => { - describe('when isBalanceLoading is true', () => { - it('returns null and does not render anything', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - - it('returns null even when isBelowMinimum is true', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - }); - - describe('when isBalanceLoading is false and isBelowMinimum is true', () => { - it('displays error message with formatted minimum bet amount', () => { - renderWithProvider( - , - ); - - expect(screen.getByText(/Minimum bet:/)).toBeOnTheScreen(); - expect(screen.getByText(/\$1\.00/)).toBeOnTheScreen(); - }); - - it('renders error text with correct styling', () => { - renderWithProvider( - , - ); - - const errorText = screen.getByText(/Minimum bet:/); - expect(errorText).toBeOnTheScreen(); - }); - - it('centers the error text', () => { - renderWithProvider( - , - ); - - const errorText = screen.getByText(/Minimum bet:/); - expect(errorText.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ textAlign: 'center' }), - ]), - ); - }); - }); - - describe('when isBalanceLoading is false and isBelowMinimum is false', () => { - it('returns null and does not render anything', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - }); - - describe('edge cases', () => { - it('handles both flags being false', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - - it('prioritizes isBalanceLoading over isBelowMinimum', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx deleted file mode 100644 index 96c57ef44db..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { - Box, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../../../locales/i18n'; -import { formatPrice } from '../../../../utils/format'; -import { MINIMUM_BET } from '../../../../constants/transactions'; - -interface PredictBuyMinimumErrorProps { - isBalanceLoading: boolean; - isBelowMinimum: boolean; -} - -const PredictBuyMinimumError = ({ - isBalanceLoading, - isBelowMinimum, -}: PredictBuyMinimumErrorProps) => { - const tw = useTailwind(); - - if (isBalanceLoading) return null; - - if (isBelowMinimum) { - return ( - - - {strings('predict.order.prediction_minimum_bet', { - amount: formatPrice(MINIMUM_BET, { - minimumDecimals: 2, - maximumDecimals: 2, - }), - })} - - - ); - } - - return null; -}; - -export default PredictBuyMinimumError; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts deleted file mode 100644 index c79e41c5253..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PredictBuyMinimumError'; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx index c77f1761c4f..f37374f9d3b 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx @@ -10,6 +10,7 @@ import { Recurrence, type PredictMarket, type PredictOutcome, + type PredictOutcomeToken, type OrderPreview, } from '../../../../types'; @@ -100,6 +101,15 @@ describe('PredictBuyPreviewHeader', () => { ...overrides, }); + const createMockOutcomeToken = ( + overrides?: Partial, + ): PredictOutcomeToken => ({ + id: 'token-1', + title: 'Yes', + price: 0.65, + ...overrides, + }); + const createMockOrderPreview = ( overrides?: Partial, ): OrderPreview => ({ @@ -124,7 +134,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByTestId('back-button')).toBeOnTheScreen(); @@ -135,7 +149,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByText(/Will Bitcoin reach \$100k\?/)).toBeOnTheScreen(); @@ -150,6 +168,7 @@ describe('PredictBuyPreviewHeader', () => { , ); @@ -167,7 +186,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByText(/Will Bitcoin reach \$100k\?/)).toBeOnTheScreen(); @@ -178,7 +201,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); @@ -191,7 +218,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.getByText(/Q1 2024/)).toBeOnTheScreen(); @@ -204,7 +235,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.queryByText(/Q1 2024/)).not.toBeOnTheScreen(); @@ -227,6 +262,11 @@ describe('PredictBuyPreviewHeader', () => { , ); @@ -234,7 +274,7 @@ describe('PredictBuyPreviewHeader', () => { expect(screen.getByText(/Yes \(alt\) at 0\.6¢/)).toBeOnTheScreen(); }); - it('falls back to first token when outcomeTokenId not found', () => { + it('falls back to outcomeToken prop when outcomeTokenId not found in outcome tokens', () => { const market = createMockMarket(); const outcome = createMockOutcome({ tokens: [ @@ -251,11 +291,16 @@ describe('PredictBuyPreviewHeader', () => { , ); - expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); + expect(screen.getByText(/Yes \(alt\) at 0\.65¢/)).toBeOnTheScreen(); }); it('uses preview sharePrice when provided', () => { @@ -272,6 +317,7 @@ describe('PredictBuyPreviewHeader', () => { , ); @@ -286,7 +332,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); const outcomeText = screen.getByText(/Yes at 0\.65¢/); @@ -301,7 +351,15 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); const outcomeText = screen.getByText(/No at 0\.35¢/); @@ -351,7 +409,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.getByText(/Will Bitcoin reach \$100k\?/)).toBeOnTheScreen(); @@ -365,7 +427,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect( @@ -380,13 +446,17 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.getByText(/Yes \(50%\+\) at 0\.65¢/)).toBeOnTheScreen(); }); - it('handles null preview', () => { + it('handles null preview by using outcomeToken prop', () => { const market = createMockMarket(); const outcome = createMockOutcome(); @@ -394,14 +464,19 @@ describe('PredictBuyPreviewHeader', () => { , ); - expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); + expect(screen.getByText(/No at 0\.35¢/)).toBeOnTheScreen(); }); - it('handles undefined preview', () => { + it('handles undefined preview by using outcomeToken prop', () => { const market = createMockMarket(); const outcome = createMockOutcome(); @@ -409,11 +484,16 @@ describe('PredictBuyPreviewHeader', () => { , ); - expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); + expect(screen.getByText(/No at 0\.35¢/)).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx index f3555963115..8d438d25d1e 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx @@ -14,12 +14,18 @@ import { useNavigation } from '@react-navigation/native'; import React from 'react'; import { Image, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../../../locales/i18n'; -import { OrderPreview, PredictMarket, PredictOutcome } from '../../../../types'; +import { + OrderPreview, + PredictMarket, + PredictOutcome, + PredictOutcomeToken, +} from '../../../../types'; import { formatCents } from '../../../../utils/format'; export interface PredictBuyPreviewHeaderProps { market: PredictMarket; outcome: PredictOutcome; + outcomeToken: PredictOutcomeToken; preview?: OrderPreview | null; onBack?: () => void; } @@ -27,16 +33,18 @@ export interface PredictBuyPreviewHeaderProps { export interface PredictBuyPreviewHeaderTitleProps { market: PredictMarket; outcome: PredictOutcome; + outcomeToken: PredictOutcomeToken; preview?: OrderPreview | null; } const getOutcomeTokenLabel = ( outcome: PredictOutcome, + outcomeToken: PredictOutcomeToken, preview?: OrderPreview | null, ) => { const selectedOutcomeToken = outcome.tokens.find((token) => token.id === preview?.outcomeTokenId) ?? - outcome.tokens[0]; + outcomeToken; const sharePrice = preview?.sharePrice ?? selectedOutcomeToken?.price ?? 0; return { @@ -48,11 +56,13 @@ const getOutcomeTokenLabel = ( export function PredictBuyPreviewHeaderTitle({ market, outcome, + outcomeToken, preview, }: PredictBuyPreviewHeaderTitleProps) { const tw = useTailwind(); const { title: outcomeTokenTitle, sharePrice } = getOutcomeTokenLabel( outcome, + outcomeToken, preview, ); @@ -137,6 +147,7 @@ export function PredictBuyPreviewHeaderBack({ const PredictBuyPreviewHeader = ({ market, outcome, + outcomeToken, preview, onBack, }: PredictBuyPreviewHeaderProps) => ( @@ -149,6 +160,7 @@ const PredictBuyPreviewHeader = ({ diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx index c12d3e02342..b9a64cd33c7 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx @@ -7,13 +7,37 @@ let mockUpdatePendingAmount = jest.fn(); let mockAmountHuman = ''; let mockUpdateTokenAmountCallback = jest.fn(); let mockActiveTransactionMeta: { id?: string } | null = null; +let mockSelectedPaymentToken: + | { + address: string; + chainId: string; + } + | undefined; +let mockPayToken: + | { + address: string; + chainId: string; + } + | undefined; +let mockSetPayToken = jest.fn(); jest.mock('../../../../hooks/usePredictPaymentToken', () => ({ usePredictPaymentToken: () => ({ isPredictBalanceSelected: mockIsPredictBalanceSelected, + selectedPaymentToken: mockSelectedPaymentToken, }), })); +jest.mock( + '../../../../../../Views/confirmations/hooks/pay/useTransactionPayToken', + () => ({ + useTransactionPayToken: () => ({ + setPayToken: mockSetPayToken, + payToken: mockPayToken, + }), + }), +); + jest.mock( '../../../../../../Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe', () => jest.fn(), @@ -57,6 +81,9 @@ describe('PredictPayWithAnyTokenInfo', () => { mockAmountHuman = ''; mockUpdateTokenAmountCallback = jest.fn(); mockActiveTransactionMeta = null; + mockSelectedPaymentToken = undefined; + mockPayToken = undefined; + mockSetPayToken = jest.fn(); }); describe('render', () => { @@ -126,14 +153,24 @@ describe('PredictPayWithAnyTokenInfo', () => { }); describe('updateTokenAmountCallback effect', () => { - it('calls updateTokenAmountCallback when amountHuman is valid', () => { + it('calls updateTokenAmountCallback with the parsed deposit amount when amountHuman is valid', () => { mockIsPredictBalanceSelected = false; mockActiveTransactionMeta = { id: 'tx-1' }; mockAmountHuman = '100.50'; render(); - expect(mockUpdateTokenAmountCallback).toHaveBeenCalledWith('100.50'); + expect(mockUpdateTokenAmountCallback).toHaveBeenCalledWith('100'); + }); + + it('uses the rounded parsed deposit amount instead of the fiat-converted amountHuman', () => { + mockIsPredictBalanceSelected = false; + mockActiveTransactionMeta = { id: 'tx-1' }; + mockAmountHuman = '2.078803'; + + render(); + + expect(mockUpdateTokenAmountCallback).toHaveBeenCalledWith('2.08'); }); it('does not call updateTokenAmountCallback when amountHuman is "0"', () => { @@ -186,4 +223,75 @@ describe('PredictPayWithAnyTokenInfo', () => { expect(mockUpdateTokenAmountCallback).not.toHaveBeenCalled(); }); }); + + describe('setPayToken effect', () => { + it('calls setPayToken when selected token is not applied', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockSelectedPaymentToken = { + address: '0xabc123', + chainId: '0x1', + }; + mockPayToken = { + address: '0xdef456', + chainId: '0x1', + }; + + render(); + + expect(mockSetPayToken).toHaveBeenCalledWith({ + address: '0xabc123', + chainId: '0x1', + }); + }); + + it('does not call setPayToken when selected token is already applied (case-insensitive)', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockSelectedPaymentToken = { + address: '0xAbC123', + chainId: '0x1', + }; + mockPayToken = { + address: '0xabc123', + chainId: '0X1', + }; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + + it('does not call setPayToken when isPredictBalanceSelected is true', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockIsPredictBalanceSelected = true; + mockSelectedPaymentToken = { + address: '0xabc123', + chainId: '0x1', + }; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + + it('does not call setPayToken when selectedPaymentToken is undefined', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockSelectedPaymentToken = undefined; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + + it('does not call setPayToken when transactionMeta is missing', () => { + mockActiveTransactionMeta = null; + mockSelectedPaymentToken = { + address: '0xabc123', + chainId: '0x1', + }; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx index 769857e43af..f72649f94ee 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx @@ -1,11 +1,12 @@ import BigNumber from 'bignumber.js'; -import { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { PREDICT_CURRENCY } from '../../../../../../Views/confirmations/constants/predict'; import { useTransactionCustomAmount } from '../../../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useTransactionMetadataRequest } from '../../../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { useUpdateTokenAmount } from '../../../../../../Views/confirmations/hooks/transactions/useUpdateTokenAmount'; import { usePredictPaymentToken } from '../../../../hooks/usePredictPaymentToken'; -import useClearConfirmationOnBackSwipe from '../../../../../../Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe'; +import { useTransactionPayToken } from '../../../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import { Hex } from '@metamask/utils'; interface PredictPayWithAnyTokenInfoProps { depositAmount: number; @@ -14,15 +15,26 @@ interface PredictPayWithAnyTokenInfoProps { const PredictPayWithAnyTokenInfo = ({ depositAmount, }: PredictPayWithAnyTokenInfoProps) => { - const { isPredictBalanceSelected } = usePredictPaymentToken(); + const transactionMeta = useTransactionMetadataRequest(); - useClearConfirmationOnBackSwipe(); + if (!transactionMeta) { + return null; + } + + return ; +}; + +function PredictPayWithAnyTokenInfoInner({ + depositAmount, +}: PredictPayWithAnyTokenInfoProps) { + const { isPredictBalanceSelected, selectedPaymentToken } = + usePredictPaymentToken(); + const { setPayToken, payToken } = useTransactionPayToken(); + const transactionMeta = useTransactionMetadataRequest(); const { updateTokenAmount: updateTokenAmountCallback } = useUpdateTokenAmount(); - const activeTransactionMeta = useTransactionMetadataRequest(); - const parsedDepositAmount = useMemo(() => { if (isPredictBalanceSelected || depositAmount <= 0) { return ''; @@ -40,11 +52,11 @@ const PredictPayWithAnyTokenInfo = ({ if ( parsedDepositAmount && parsedDepositAmount.trim() !== '' && - activeTransactionMeta + transactionMeta ) { updatePendingAmount(parsedDepositAmount); } - }, [parsedDepositAmount, activeTransactionMeta, updatePendingAmount]); + }, [parsedDepositAmount, transactionMeta, updatePendingAmount]); useEffect(() => { if ( @@ -52,19 +64,44 @@ const PredictPayWithAnyTokenInfo = ({ amountHuman !== '0' && parsedDepositAmount && parsedDepositAmount.trim() !== '' && - activeTransactionMeta + transactionMeta ) { - updateTokenAmountCallback(amountHuman); + updateTokenAmountCallback(parsedDepositAmount); } }, [ amountHuman, - depositAmount, - activeTransactionMeta, + transactionMeta, updateTokenAmountCallback, parsedDepositAmount, ]); + useEffect(() => { + if (!transactionMeta || isPredictBalanceSelected || !selectedPaymentToken) { + return; + } + + const hasSelectedTokenApplied = + payToken?.address?.toLowerCase() === + selectedPaymentToken.address.toLowerCase() && + payToken?.chainId?.toLowerCase() === + selectedPaymentToken.chainId.toLowerCase(); + + if (!hasSelectedTokenApplied) { + setPayToken({ + address: selectedPaymentToken.address as Hex, + chainId: selectedPaymentToken.chainId as Hex, + }); + } + }, [ + transactionMeta, + isPredictBalanceSelected, + selectedPaymentToken, + payToken?.address, + payToken?.chainId, + setPayToken, + ]); + return null; -}; +} export default PredictPayWithAnyTokenInfo; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx index 87f171705d0..6ac4a0c5659 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx @@ -140,7 +140,6 @@ describe('PredictPayWithRow', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CONFIRMATION_PAY_WITH_MODAL, - { isPredictContext: true }, ); }); @@ -178,12 +177,12 @@ describe('PredictPayWithRow', () => { expect(tree).not.toContain('ArrowDown'); }); - it('falls back to empty string when no symbols available', () => { + it('falls back to Predict balance when payToken is null', () => { mockPayToken = null; renderWithProvider(); - expect(screen.getByText('Pay with')).toBeOnTheScreen(); + expect(screen.getByText('Pay with Predict balance')).toBeOnTheScreen(); }); it('renders with no transactionMeta without crashing', () => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx index 6b39d7e065b..4a74eb564a8 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx @@ -41,21 +41,21 @@ export function PredictPayWithRow({ const { isPredictBalanceSelected, selectedPaymentToken } = usePredictPaymentToken(); + const showPredictBalance = isPredictBalanceSelected || !payToken; + const handlePress = useCallback(() => { if (!canEdit) return; - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { - isPredictContext: true, - }); + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); }, [canEdit, navigation]); const label = strings('confirm.label.pay_with'); - const displaySymbol = isPredictBalanceSelected + const displaySymbol = showPredictBalance ? 'Predict balance' : (selectedPaymentToken?.symbol ?? payToken?.symbol ?? ''); - const tokenIconAddress = isPredictBalanceSelected + const tokenIconAddress = showPredictBalance ? POLYGON_USDCE.address : (payToken?.address as Hex | undefined); - const tokenIconChainId = isPredictBalanceSelected + const tokenIconChainId = showPredictBalance ? PREDICT_BALANCE_CHAIN_ID : (payToken?.chainId as Hex | undefined); @@ -65,7 +65,7 @@ export function PredictPayWithRow({ flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Center} - twClassName="rounded-full bg-default p-4" + twClassName={`rounded-full py-2 pl-[9px] pr-[16px] mt-2 ${disabled ? '' : 'bg-muted'}`} gap={3} > {tokenIconAddress && tokenIconChainId && ( diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts new file mode 100644 index 00000000000..8779e581774 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts @@ -0,0 +1,474 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { StackActions } from '@react-navigation/native'; +import { usePredictBuyActions } from './usePredictBuyActions'; +import { PREDICT_ERROR_CODES } from '../../../constants/errors'; +import { + ActiveOrderState, + OrderPreview, + PlaceOrderParams, + Side, +} from '../../../types'; + +const mockDispatch = jest.fn(); +const mockOnConfirmActionsReject = jest.fn(); +const mockOnApprovalConfirm = jest.fn(); +const mockUnsubscribe = jest.fn(); +const mockShowOrderPlacedToast = jest.fn(); +const mockInvalidateOrderQueries = jest.fn(); +const mockTrackPredictOrderEvent = jest.fn(); +const mockPlaceOrder = jest.fn, [PlaceOrderParams]>(); +const mockOnPlaceOrderEnd = jest.fn(); +const mockOnOrderCancelled = jest.fn(); +const mockInitPayWithAnyToken = jest.fn(); +const mockSetIsConfirming = jest.fn(); +const mockTransitionEndUnsubscribe = jest.fn(); +const mockBeforeRemoveUnsubscribe = jest.fn(); +const mockTransitionEndCallbacks: ((e: { + data: { closing: boolean }; +}) => void)[] = []; +const mockBeforeRemoveCallbacks: (() => void)[] = []; + +let mockActiveOrder: { + batchId?: string | null; + state?: ActiveOrderState; +} | null = null; +let mockPayWithAnyTokenEnabled = true; +let mockApprovalRequest: { id: string } | undefined; + +const createAddListenerMock = + () => + (event: string, callback: (e?: { data: { closing: boolean } }) => void) => { + if (event === 'transitionEnd') { + mockTransitionEndCallbacks.push( + callback as (e: { data: { closing: boolean } }) => void, + ); + callback({ data: { closing: false } }); + return mockTransitionEndUnsubscribe; + } + + if (event === 'beforeRemove') { + mockBeforeRemoveCallbacks.push(callback as () => void); + return () => { + callback(); + mockBeforeRemoveUnsubscribe(); + }; + } + + return mockUnsubscribe; + }; + +const mockAddListener = jest.fn(createAddListenerMock()); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + dispatch: mockDispatch, + addListener: mockAddListener, + }), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => mockPayWithAnyTokenEnabled), +})); + +jest.mock( + '../../../../../Views/confirmations/hooks/useApprovalRequest', + () => ({ + __esModule: true, + default: () => ({ + onConfirm: mockOnApprovalConfirm, + approvalRequest: mockApprovalRequest, + }), + }), +); + +jest.mock('../../../../../Views/confirmations/hooks/useConfirmActions', () => ({ + useConfirmActions: () => ({ + onReject: mockOnConfirmActionsReject, + }), +})); + +jest.mock('../../../hooks/usePredictActiveOrder', () => ({ + usePredictActiveOrder: () => ({ + activeOrder: mockActiveOrder, + }), +})); + +jest.mock('../../../hooks/usePredictTrading', () => ({ + usePredictTrading: () => ({ + placeOrder: mockPlaceOrder, + initPayWithAnyToken: mockInitPayWithAnyToken, + }), +})); + +jest.mock('../../../../../../core/Engine', () => ({ + context: { + PredictController: { + onPlaceOrderEnd: (...args: unknown[]) => mockOnPlaceOrderEnd(...args), + onOrderCancelled: (...args: unknown[]) => mockOnOrderCancelled(...args), + trackPredictOrderEvent: (...args: unknown[]) => + mockTrackPredictOrderEvent(...args), + initPayWithAnyToken: (...args: unknown[]) => + mockInitPayWithAnyToken(...args), + }, + }, +})); + +const createDefaultParams = (): Parameters[0] => ({ + preview: { + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + timestamp: Date.now(), + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 175, + minAmountReceived: 180, + slippage: 0.005, + tickSize: 0.01, + minOrderSize: 0.01, + negRisk: false, + fees: { totalFee: 5 }, + } as OrderPreview, + analyticsProperties: { marketId: 'market-1' }, + setIsConfirming: mockSetIsConfirming, + showOrderPlacedToast: mockShowOrderPlacedToast, + invalidateOrderQueries: mockInvalidateOrderQueries, +}); + +describe('usePredictBuyActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActiveOrder = null; + mockPayWithAnyTokenEnabled = true; + mockApprovalRequest = undefined; + mockInitPayWithAnyToken.mockResolvedValue(undefined); + mockTransitionEndCallbacks.length = 0; + mockBeforeRemoveCallbacks.length = 0; + mockAddListener.mockImplementation(createAddListenerMock()); + }); + + describe('mount effect', () => { + it('tracks an initiated order event on mount', () => { + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockTrackPredictOrderEvent).toHaveBeenCalledWith({ + status: 'initiated', + analyticsProperties: { marketId: 'market-1' }, + sharePrice: undefined, + }); + }); + + it('calls initPayWithAnyToken on mount when pay with any token is enabled', () => { + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockInitPayWithAnyToken).toHaveBeenCalledTimes(1); + }); + + it('does not call initPayWithAnyToken when pay with any token is disabled', () => { + mockPayWithAnyTokenEnabled = false; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockInitPayWithAnyToken).not.toHaveBeenCalled(); + }); + + it('rejects approval request on unmount when pay with any token is enabled', () => { + const { unmount } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + unmount(); + + expect(mockTransitionEndUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockBeforeRemoveUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockOnConfirmActionsReject).toHaveBeenCalledTimes(1); + }); + + it('only calls initPayWithAnyToken once even if transitionEnd fires again', () => { + const transitionEndCallbacks: ((e: { + data: { closing: boolean }; + }) => void)[] = []; + + mockAddListener.mockImplementation( + ( + event: string, + callback: + | ((e: { data: { closing: boolean } }) => void) + | (() => void), + ) => { + if (event === 'transitionEnd') { + const typedCallback = callback as (e: { + data: { closing: boolean }; + }) => void; + + transitionEndCallbacks.push(typedCallback); + typedCallback({ data: { closing: false } }); + + return mockTransitionEndUnsubscribe; + } + + if (event === 'beforeRemove') { + return mockBeforeRemoveUnsubscribe; + } + + return mockUnsubscribe; + }, + ); + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + act(() => { + transitionEndCallbacks[0]({ data: { closing: false } }); + }); + + expect(mockInitPayWithAnyToken).toHaveBeenCalledTimes(1); + }); + + it('does not initialize pay with any token when transitionEnd is closing', () => { + renderHook(() => usePredictBuyActions(createDefaultParams())); + + mockInitPayWithAnyToken.mockClear(); + + act(() => { + mockTransitionEndCallbacks[0]({ data: { closing: true } }); + }); + + expect(mockInitPayWithAnyToken).not.toHaveBeenCalled(); + }); + + it('does not register cleanup listeners when pay with any token is disabled', () => { + mockPayWithAnyTokenEnabled = false; + + const { unmount } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + unmount(); + + expect(mockOnConfirmActionsReject).not.toHaveBeenCalled(); + expect(mockOnPlaceOrderEnd).not.toHaveBeenCalled(); + }); + }); + + describe('handleConfirm', () => { + it('sets isConfirming to true', async () => { + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockSetIsConfirming).toHaveBeenCalledWith(true); + }); + + it('calls placeOrder with preview and analyticsProperties', async () => { + const params = createDefaultParams(); + const { result } = renderHook(() => usePredictBuyActions(params)); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-1' }, + preview: params.preview, + }); + }); + + it('calls approval confirm when the order is paying with any token', async () => { + mockActiveOrder = { state: ActiveOrderState.PAY_WITH_ANY_TOKEN }; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockOnApprovalConfirm).toHaveBeenCalledWith({ + deleteAfterResult: true, + waitForResult: true, + handleErrors: false, + }); + }); + + it('does not call placeOrder when preview is null', async () => { + const params = createDefaultParams(); + params.preview = null; + const { result } = renderHook(() => usePredictBuyActions(params)); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).not.toHaveBeenCalled(); + }); + + it('returns a preview not available error when preview is null', async () => { + const params = createDefaultParams(); + params.preview = null; + const { result } = renderHook(() => usePredictBuyActions(params)); + + let outcome; + await act(async () => { + outcome = await result.current.handleConfirm(); + }); + + expect(outcome).toEqual({ + status: 'error', + error: PREDICT_ERROR_CODES.PREVIEW_NOT_AVAILABLE, + }); + }); + + it('passes transactionId from approvalRequest when state is PAY_WITH_ANY_TOKEN', async () => { + mockActiveOrder = { state: ActiveOrderState.PAY_WITH_ANY_TOKEN }; + mockApprovalRequest = { id: 'approval-tx-123' }; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith( + expect.objectContaining({ transactionId: 'approval-tx-123' }), + ); + }); + + it('passes undefined transactionId when state is PREVIEW (balance flow)', async () => { + mockActiveOrder = { state: ActiveOrderState.PREVIEW }; + mockApprovalRequest = { id: 'approval-tx-456' }; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith( + expect.objectContaining({ transactionId: undefined }), + ); + }); + + it('passes undefined transactionId when approvalRequest is undefined', async () => { + mockActiveOrder = { state: ActiveOrderState.PAY_WITH_ANY_TOKEN }; + mockApprovalRequest = undefined; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith( + expect.objectContaining({ transactionId: undefined }), + ); + }); + }); + + describe('placeOrder helper', () => { + it('returns a success result when placeOrder resolves', async () => { + const placeOrderResult = { success: true }; + mockPlaceOrder.mockResolvedValue(placeOrderResult); + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + let outcome; + await act(async () => { + outcome = await result.current.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview: createDefaultParams().preview as OrderPreview, + }); + }); + + expect(outcome).toEqual({ + status: 'success', + result: placeOrderResult, + }); + }); + + it('returns the error message when placeOrder rejects with an Error', async () => { + mockPlaceOrder.mockRejectedValue(new Error('Order failed')); + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + let outcome; + await act(async () => { + outcome = await result.current.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview: createDefaultParams().preview as OrderPreview, + }); + }); + + expect(outcome).toEqual({ + status: 'error', + error: 'Order failed', + }); + }); + + it('returns the default error when placeOrder rejects with a non-Error value', async () => { + mockPlaceOrder.mockRejectedValue('unexpected failure'); + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + let outcome; + await act(async () => { + outcome = await result.current.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview: createDefaultParams().preview as OrderPreview, + }); + }); + + expect(outcome).toEqual({ + status: 'error', + error: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }); + }); + }); + + describe('confirming state effect', () => { + it.each([ActiveOrderState.DEPOSITING, ActiveOrderState.PLACING_ORDER])( + 'sets isConfirming to true in %s state', + (state) => { + mockActiveOrder = { state }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockSetIsConfirming).toHaveBeenCalledWith(true); + }, + ); + + it.each([ActiveOrderState.PREVIEW, ActiveOrderState.PAY_WITH_ANY_TOKEN])( + 'sets isConfirming to false in %s state', + (state) => { + mockActiveOrder = { state }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockSetIsConfirming).toHaveBeenCalledWith(false); + }, + ); + }); + + describe('success effect', () => { + it('shows toast, cleans up and closes the screen in SUCCESS state', async () => { + mockActiveOrder = { state: ActiveOrderState.SUCCESS }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockInvalidateOrderQueries).toHaveBeenCalledTimes(1); + expect(mockShowOrderPlacedToast).toHaveBeenCalledTimes(1); + expect(mockOnPlaceOrderEnd).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts new file mode 100644 index 00000000000..6bc641596ac --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts @@ -0,0 +1,183 @@ +import { StackActions, useNavigation } from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import type { PredictNavigationParamList } from '../../../types/navigation'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + ActiveOrderState, + OrderPreview, + PlaceOrderParams, +} from '../../../types'; +import useApprovalRequest from '../../../../../Views/confirmations/hooks/useApprovalRequest'; +import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import Engine from '../../../../../../core/Engine'; +import { useSelector } from 'react-redux'; +import { selectPredictWithAnyTokenEnabledFlag } from '../../../selectors/featureFlags'; +import { PredictTradeStatus } from '../../../constants/eventNames'; +import { usePredictTrading } from '../../../hooks/usePredictTrading'; +import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; +import { PREDICT_ERROR_CODES } from '../../../constants/errors'; +import { useConfirmActions } from '../../../../../Views/confirmations/hooks/useConfirmActions'; + +interface UsePredictBuyActionsParams { + preview?: OrderPreview | null; + analyticsProperties: PlaceOrderParams['analyticsProperties']; + setIsConfirming: (value: boolean) => void; + showOrderPlacedToast: () => void; + invalidateOrderQueries: () => void; +} + +export const usePredictBuyActions = ({ + preview, + analyticsProperties, + setIsConfirming, + showOrderPlacedToast, + invalidateOrderQueries, +}: UsePredictBuyActionsParams) => { + const navigation = + useNavigation>(); + const { onConfirm: onApprovalConfirm, approvalRequest } = + useApprovalRequest(); + const { onReject } = useConfirmActions(); + const { activeOrder } = usePredictActiveOrder(); + const { placeOrder, initPayWithAnyToken } = usePredictTrading(); + const currentState = useMemo(() => activeOrder?.state, [activeOrder?.state]); + const { PredictController } = Engine.context; + const payWithAnyTokenEnabled = useSelector( + selectPredictWithAnyTokenEnabledFlag, + ); + + const hasInitializedPayWithAnyTokenRef = useRef(false); + + useEffect(() => { + const controller = Engine.context.PredictController; + + controller.trackPredictOrderEvent({ + status: PredictTradeStatus.INITIATED, + analyticsProperties, + sharePrice: analyticsProperties?.sharePrice, + }); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!payWithAnyTokenEnabled) { + return; + } + + const unsubscribe = navigation.addListener('transitionEnd', (e) => { + if (!e.data.closing && !hasInitializedPayWithAnyTokenRef.current) { + hasInitializedPayWithAnyTokenRef.current = true; + initPayWithAnyToken(); + } + }); + + return unsubscribe; + }, [navigation, initPayWithAnyToken, payWithAnyTokenEnabled]); + + useEffect(() => { + if (!payWithAnyTokenEnabled) { + return; + } + + return navigation.addListener('beforeRemove', () => { + onReject(undefined, true); + PredictController.onPlaceOrderEnd(); + }); + }, [navigation, payWithAnyTokenEnabled, PredictController, onReject]); + + const handlePlaceOrder = useCallback( + async (orderParams: PlaceOrderParams): Promise => { + try { + const result = await placeOrder(orderParams); + return { status: 'success', result }; + } catch (error) { + return { + status: 'error', + error: + error instanceof Error + ? error.message + : PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }; + } + }, + [placeOrder], + ); + + const handleConfirm = useCallback(async () => { + setIsConfirming(true); + + // Only capture transactionId for PAY_WITH_ANY_TOKEN flow (deposit-order linking). + // Balance flow doesn't need it — passing undefined lets isCurrentActiveBuyOrder + // match without a strict transactionId check. + const transactionId = + currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN + ? approvalRequest?.id + : undefined; + + if (currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN) { + onApprovalConfirm({ + deleteAfterResult: true, + waitForResult: true, + handleErrors: false, + }); + } + if (!preview) { + return { + status: 'error', + error: PREDICT_ERROR_CODES.PREVIEW_NOT_AVAILABLE, + }; + } + + return handlePlaceOrder({ + analyticsProperties, + preview, + transactionId, + }); + }, [ + setIsConfirming, + approvalRequest, + currentState, + handlePlaceOrder, + analyticsProperties, + preview, + onApprovalConfirm, + ]); + + useEffect(() => { + if ( + currentState === ActiveOrderState.DEPOSITING || + currentState === ActiveOrderState.PLACING_ORDER + ) { + setIsConfirming(true); + } + + if ( + currentState === ActiveOrderState.PREVIEW || + currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN + ) { + setIsConfirming(false); + } + }, [currentState, setIsConfirming]); + + useEffect(() => { + if (currentState === ActiveOrderState.SUCCESS) { + invalidateOrderQueries(); + showOrderPlacedToast(); + PredictController.onPlaceOrderEnd(); + navigation.dispatch(StackActions.pop()); + } + }, [ + PredictController, + currentState, + invalidateOrderQueries, + navigation, + setIsConfirming, + showOrderPlacedToast, + ]); + + return { + handleConfirm, + placeOrder: handlePlaceOrder, + }; +}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts index b56d859f2ca..209f67c04cb 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts @@ -1,5 +1,4 @@ import { renderHook } from '@testing-library/react-native'; -import { formatPrice } from '../../../utils/format'; import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; let mockIsPredictBalanceSelected = true; @@ -29,10 +28,6 @@ jest.mock( }), ); -jest.mock('../../../utils/format', () => ({ - formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), -})); - describe('usePredictBuyAvailableBalance', () => { beforeEach(() => { jest.clearAllMocks(); @@ -43,105 +38,60 @@ describe('usePredictBuyAvailableBalance', () => { }); describe('availableBalance', () => { - it('returns formatted Predict balance when isPredictBalanceSelected is true', () => { - // Arrange + it('returns Predict balance when isPredictBalanceSelected is true', () => { mockIsPredictBalanceSelected = true; mockBalance = 250.5; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$250.50'); + expect(result.current.availableBalance).toBe(250.5); }); - it('returns formatted payToken balanceUsd when isPredictBalanceSelected is false', () => { - // Arrange + it('returns predict balance plus payToken balanceUsd when isPredictBalanceSelected is false', () => { mockIsPredictBalanceSelected = false; + mockBalance = 100; mockPayToken = { balanceUsd: 150.75 }; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$150.75'); + expect(result.current.availableBalance).toBe(250.75); }); - it('returns "$0.00" when payToken has no balanceUsd and isPredictBalanceSelected is false', () => { - // Arrange + it('returns predict balance when payToken has no balanceUsd and isPredictBalanceSelected is false', () => { mockIsPredictBalanceSelected = false; + mockBalance = 100; mockPayToken = {}; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$0.00'); + expect(result.current.availableBalance).toBe(100); }); - it('returns "$0.00" when payToken is null and isPredictBalanceSelected is false', () => { - // Arrange + it('falls back to Predict balance when payToken is null', () => { mockIsPredictBalanceSelected = false; mockPayToken = null; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$0.00'); + expect(result.current.availableBalance).toBe(100); }); }); describe('isBalanceLoading', () => { it('returns isBalanceLoading from usePredictBalance', () => { - // Arrange mockIsBalanceLoading = true; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert expect(result.current.isBalanceLoading).toBe(true); }); it('returns false when balance is not loading', () => { - // Arrange mockIsBalanceLoading = false; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert expect(result.current.isBalanceLoading).toBe(false); }); }); - - describe('formatPrice', () => { - it('calls formatPrice with correct options when using Predict balance', () => { - // Arrange - mockIsPredictBalanceSelected = true; - mockBalance = 500; - - // Act - renderHook(() => usePredictBuyAvailableBalance()); - - // Assert - expect(formatPrice).toHaveBeenCalledWith(500, { - minimumDecimals: 2, - maximumDecimals: 2, - }); - }); - - it('does not call formatPrice when using payToken balance', () => { - // Arrange - mockIsPredictBalanceSelected = false; - mockPayToken = { balanceUsd: 100 }; - - // Act - renderHook(() => usePredictBuyAvailableBalance()); - - // Assert - expect(formatPrice).not.toHaveBeenCalled(); - }); - }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts index 1575573f080..38727367274 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react'; -import { formatPrice } from '../../../utils/format'; +import { useTransactionPayToken } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; import { usePredictBalance } from '../../../hooks/usePredictBalance'; import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; -import { useTransactionPayToken } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; export const usePredictBuyAvailableBalance = () => { const { isPredictBalanceSelected } = usePredictPaymentToken(); @@ -12,13 +11,10 @@ export const usePredictBuyAvailableBalance = () => { const availableBalance = useMemo( () => - isPredictBalanceSelected - ? formatPrice(balance, { - minimumDecimals: 2, - maximumDecimals: 2, - }) - : `$${Number(payToken?.balanceUsd ?? 0).toFixed(2)}`, - [isPredictBalanceSelected, balance, payToken?.balanceUsd], + isPredictBalanceSelected || !payToken + ? balance + : balance + Number(payToken?.balanceUsd ?? 0), + [isPredictBalanceSelected, payToken, balance], ); return { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts deleted file mode 100644 index 68992207556..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { BackHandler } from 'react-native'; -import Device from '../../../../../../util/device'; -import usePredictBuyBackSwipe from './usePredictBuyBackSwipe'; - -const mockAddListener = jest.fn(); -const mockUnsubscribe = jest.fn(); -const mockRemove = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - addListener: mockAddListener, - }), -})); - -jest.mock('../../../../../../util/device', () => ({ - __esModule: true, - default: { isAndroid: jest.fn() }, -})); - -jest.mock('react-native', () => { - const actual = jest.requireActual('react-native'); - return { - ...actual, - BackHandler: { - addEventListener: jest.fn(() => ({ remove: mockRemove })), - }, - }; -}); - -describe('usePredictBuyBackSwipe', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockAddListener.mockReturnValue(mockUnsubscribe); - }); - - describe('gestureEnd listener', () => { - it('registers gestureEnd listener on navigation', () => { - const onBack = jest.fn(); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(mockAddListener).toHaveBeenCalledWith( - 'gestureEnd', - expect.any(Function), - ); - }); - - it('calls onBack when gestureEnd fires', () => { - const onBack = jest.fn(); - mockAddListener.mockImplementation( - (event: string, callback: () => void) => { - if (event === 'gestureEnd') { - callback(); - } - return mockUnsubscribe; - }, - ); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(onBack).toHaveBeenCalled(); - }); - - it('unsubscribes gestureEnd listener on unmount', () => { - const onBack = jest.fn(); - - const { unmount } = renderHook(() => usePredictBuyBackSwipe({ onBack })); - - unmount(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - }); - - describe('BackHandler on Android', () => { - beforeEach(() => { - (Device.isAndroid as jest.Mock).mockReturnValue(true); - }); - - it('registers BackHandler on Android', () => { - const onBack = jest.fn(); - const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener'); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'hardwareBackPress', - expect.any(Function), - ); - }); - - it('calls onBack when hardware back pressed on Android', () => { - const onBack = jest.fn(); - let capturedCallback: (() => boolean | null | undefined) | null = null; - - jest - .spyOn(BackHandler, 'addEventListener') - .mockImplementation((_eventName, handler) => { - capturedCallback = handler; - return { remove: mockRemove }; - }); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - if (capturedCallback) { - (capturedCallback as () => boolean)(); - } - - expect(onBack).toHaveBeenCalled(); - }); - - it('returns true from hardware back handler to prevent default', () => { - const onBack = jest.fn(); - let capturedCallback: (() => boolean | null | undefined) | null = null; - - jest - .spyOn(BackHandler, 'addEventListener') - .mockImplementation((_eventName, handler) => { - capturedCallback = handler; - return { remove: mockRemove }; - }); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - let result = false; - if (capturedCallback) { - result = (capturedCallback as () => boolean)(); - } - - expect(result).toBe(true); - }); - - it('removes BackHandler subscription on unmount', () => { - const onBack = jest.fn(); - - jest - .spyOn(BackHandler, 'addEventListener') - .mockImplementation(() => ({ remove: mockRemove })); - - const { unmount } = renderHook(() => usePredictBuyBackSwipe({ onBack })); - - unmount(); - - expect(mockRemove).toHaveBeenCalled(); - }); - }); - - describe('BackHandler on iOS', () => { - beforeEach(() => { - (Device.isAndroid as jest.Mock).mockReturnValue(false); - }); - - it('does not register BackHandler on iOS', () => { - const onBack = jest.fn(); - const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener'); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(addEventListenerSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts deleted file mode 100644 index 5005ec57367..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ParamListBase, useNavigation } from '@react-navigation/native'; -import { StackNavigationProp } from '@react-navigation/stack'; -import { useEffect } from 'react'; -import { BackHandler } from 'react-native'; -import Device from '../../../../../../util/device'; - -const usePredictBuyBackSwipe = ({ onBack }: { onBack: () => void }) => { - const navigation = useNavigation>(); - - useEffect(() => { - const unsubscribe = navigation.addListener('gestureEnd', () => { - onBack(); - }); - - return unsubscribe; - }, [navigation, onBack]); - - useEffect(() => { - if (Device.isAndroid()) { - const backHandlerSubscription = BackHandler.addEventListener( - 'hardwareBackPress', - () => { - onBack(); - return true; - }, - ); - - return () => { - backHandlerSubscription.remove(); - }; - } - }, [onBack]); -}; - -export default usePredictBuyBackSwipe; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts index cfa260031d2..82b4d3bdb0b 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts @@ -1,8 +1,10 @@ import { renderHook } from '@testing-library/react-native'; import { usePredictBuyConditions } from './usePredictBuyConditions'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { ActiveOrderState, OrderPreview } from '../../../types'; let mockIsBalanceLoading = false; +let mockAvailableBalance = 100; let mockActiveOrder: { state?: string } | null = null; let mockPayTotals: Record | null = null; let mockIsPayTotalsLoading = false; @@ -19,10 +21,14 @@ let mockSelectedPaymentToken: { chainId?: string; } | null = null; let mockIsDepositPending = false; +let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null; +let mockPredictBalance = 0; +const mockResetSelectedPaymentToken = jest.fn(); jest.mock('./usePredictBuyAvailableBalance', () => ({ usePredictBuyAvailableBalance: () => ({ isBalanceLoading: mockIsBalanceLoading, + availableBalance: mockAvailableBalance, }), })); @@ -36,6 +42,13 @@ jest.mock('../../../hooks/usePredictPaymentToken', () => ({ usePredictPaymentToken: () => ({ isPredictBalanceSelected: mockIsPredictBalanceSelected, selectedPaymentToken: mockSelectedPaymentToken, + resetSelectedPaymentToken: mockResetSelectedPaymentToken, + }), +})); + +jest.mock('../../../hooks/usePredictBalance', () => ({ + usePredictBalance: () => ({ + data: mockPredictBalance, }), })); @@ -46,6 +59,15 @@ jest.mock('../../../hooks/usePredictDeposit', () => ({ }), })); +jest.mock( + '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert', + () => ({ + useInsufficientPayTokenBalanceAlert: () => [ + mockInsufficientPayTokenBalanceAlert, + ], + }), +); + jest.mock( '../../../../../Views/confirmations/hooks/pay/useTransactionPayData', () => ({ @@ -57,19 +79,32 @@ jest.mock( }), ); +jest.mock('@metamask/assets-controllers', () => ({ + getNativeTokenAddress: jest.fn( + () => '0x0000000000000000000000000000000000001010', + ), +})); + const defaultParams = { currentValue: 10, - preview: { rateLimited: false } as OrderPreview | null, + depositFee: 0, + preview: { + rateLimited: false, + maxAmountSpent: 10, + fees: { totalFee: 0.5 }, + } as OrderPreview | null, isPreviewCalculating: false, - isPlaceOrderLoading: false, isUserInputChange: false, isConfirming: false, + totalPayForPredictBalance: 0, + isInputFocused: false, }; describe('usePredictBuyConditions', () => { beforeEach(() => { jest.clearAllMocks(); mockIsBalanceLoading = false; + mockAvailableBalance = 100; mockActiveOrder = null; mockPayTotals = null; mockIsPayTotalsLoading = false; @@ -79,6 +114,12 @@ describe('usePredictBuyConditions', () => { mockIsPredictBalanceSelected = true; mockSelectedPaymentToken = null; mockIsDepositPending = false; + mockInsufficientPayTokenBalanceAlert = null; + mockPredictBalance = 0; + }); + + afterEach(() => { + jest.mocked(getNativeTokenAddress).mockClear(); }); describe('isBelowMinimum', () => { @@ -115,76 +156,138 @@ describe('usePredictBuyConditions', () => { }); }); - describe('isRateLimited', () => { - it('returns true when preview.rateLimited is true', () => { + describe('isInsufficientBalance', () => { + it('returns true when currentValue exceeds maxBetAmount', () => { + mockAvailableBalance = 5; + const { result } = renderHook(() => usePredictBuyConditions({ ...defaultParams, - preview: { rateLimited: true } as OrderPreview, + currentValue: 10, }), ); - expect(result.current.isRateLimited).toBe(true); + expect(result.current.isInsufficientBalance).toBe(true); }); - it('returns false when preview is null', () => { + it('returns false when currentValue is within maxBetAmount', () => { + mockAvailableBalance = 100; + const { result } = renderHook(() => - usePredictBuyConditions({ ...defaultParams, preview: null }), + usePredictBuyConditions({ + ...defaultParams, + currentValue: 10, + }), ); - expect(result.current.isRateLimited).toBe(false); + expect(result.current.isInsufficientBalance).toBe(false); }); - it('returns false when preview.rateLimited is false', () => { + it('returns false when currentValue equals maxBetAmount', () => { + mockAvailableBalance = 10.5; + const { result } = renderHook(() => usePredictBuyConditions({ ...defaultParams, - preview: { rateLimited: false } as OrderPreview, + currentValue: 10, + preview: { + rateLimited: false, + maxAmountSpent: 10, + fees: { totalFee: 0.5, totalFeePercentage: 5 }, + } as OrderPreview, }), ); - expect(result.current.isRateLimited).toBe(false); + expect(result.current.isInsufficientBalance).toBe(false); + }); + + it('returns false when currentValue is 0', () => { + mockAvailableBalance = 0; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + currentValue: 0, + }), + ); + + expect(result.current.isInsufficientBalance).toBe(false); }); }); - describe('isPlacingOrder', () => { - it('returns true when activeOrder state is PLACING_ORDER', () => { - mockActiveOrder = { state: ActiveOrderState.PLACING_ORDER }; + describe('maxBetAmount', () => { + it('returns balance divided by (1 + feeRate) when fees apply', () => { + mockAvailableBalance = 104; const { result } = renderHook(() => - usePredictBuyConditions(defaultParams), + usePredictBuyConditions({ + ...defaultParams, + preview: { + rateLimited: false, + fees: { totalFeePercentage: 4 }, + } as OrderPreview, + }), ); - expect(result.current.isPlacingOrder).toBe(true); + expect(result.current.maxBetAmount).toBe(100); }); - it('returns true when isPlaceOrderLoading is true', () => { + it('returns full available balance when fee rate is 0', () => { + mockAvailableBalance = 50; + const { result } = renderHook(() => usePredictBuyConditions({ ...defaultParams, - isPlaceOrderLoading: true, + preview: { + rateLimited: false, + fees: { totalFeePercentage: 0 }, + } as OrderPreview, }), ); - expect(result.current.isPlacingOrder).toBe(true); + expect(result.current.maxBetAmount).toBe(50); }); - it('returns true when activeOrder state is DEPOSITING', () => { - mockActiveOrder = { state: ActiveOrderState.DEPOSITING }; + it('returns full available balance when preview has no fees', () => { + mockAvailableBalance = 50; const { result } = renderHook(() => usePredictBuyConditions(defaultParams), ); - expect(result.current.isPlacingOrder).toBe(true); + expect(result.current.maxBetAmount).toBe(50); }); + }); - it('returns false when none of the placing conditions are met', () => { + describe('isRateLimited', () => { + it('returns true when preview.rateLimited is true', () => { const { result } = renderHook(() => - usePredictBuyConditions(defaultParams), + usePredictBuyConditions({ + ...defaultParams, + preview: { rateLimited: true } as OrderPreview, + }), ); - expect(result.current.isPlacingOrder).toBe(false); + expect(result.current.isRateLimited).toBe(true); + }); + + it('returns false when preview is null', () => { + const { result } = renderHook(() => + usePredictBuyConditions({ ...defaultParams, preview: null }), + ); + + expect(result.current.isRateLimited).toBe(false); + }); + + it('returns false when preview.rateLimited is false', () => { + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + preview: { rateLimited: false } as OrderPreview, + }), + ); + + expect(result.current.isRateLimited).toBe(false); }); }); @@ -213,17 +316,6 @@ describe('usePredictBuyConditions', () => { expect(result.current.canPlaceBet).toBe(false); }); - it('returns false when isPlaceOrderLoading is true', () => { - const { result } = renderHook(() => - usePredictBuyConditions({ - ...defaultParams, - isPlaceOrderLoading: true, - }), - ); - - expect(result.current.canPlaceBet).toBe(false); - }); - it('returns false when isBalanceLoading is true', () => { mockIsBalanceLoading = true; @@ -242,6 +334,16 @@ describe('usePredictBuyConditions', () => { expect(result.current.canPlaceBet).toBe(false); }); + it('returns false when isInsufficientBalance', () => { + mockAvailableBalance = 5; + + const { result } = renderHook(() => + usePredictBuyConditions({ ...defaultParams, currentValue: 10.5 }), + ); + + expect(result.current.canPlaceBet).toBe(false); + }); + it('returns false when isRateLimited', () => { const { result } = renderHook(() => usePredictBuyConditions({ @@ -264,6 +366,19 @@ describe('usePredictBuyConditions', () => { expect(result.current.canPlaceBet).toBe(false); }); + + it('returns false when external payment token balance is insufficient', () => { + mockIsPredictBalanceSelected = false; + mockInsufficientPayTokenBalanceAlert = { + message: 'Insufficient payment token balance', + }; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.canPlaceBet).toBe(false); + }); }); describe('isPayFeesLoading', () => { @@ -301,14 +416,25 @@ describe('usePredictBuyConditions', () => { expect(result.current.isPayFeesLoading).toBe(true); }); - it('returns true when activeOrder state is REDIRECTING', () => { - mockActiveOrder = { state: ActiveOrderState.REDIRECTING }; + it('returns false when activeOrder state does not affect pay fees loading', () => { + mockActiveOrder = { state: ActiveOrderState.DEPOSITING }; const { result } = renderHook(() => usePredictBuyConditions(defaultParams), ); - expect(result.current.isPayFeesLoading).toBe(true); + expect(result.current.isPayFeesLoading).toBe(false); + }); + + it('returns false when source amount has not been set yet', () => { + mockIsPredictBalanceSelected = false; + mockSelectedPaymentToken = { address: '0xabc', chainId: '0x1' }; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.isPayFeesLoading).toBe(false); }); }); @@ -364,7 +490,45 @@ describe('usePredictBuyConditions', () => { expect(result.current.isPayFeesLoading).toBe(true); }); - it('returns false when requiredTokens include selected token', () => { + it('returns false when Polygon native token quote uses zero address', () => { + mockIsPredictBalanceSelected = false; + mockSelectedPaymentToken = { + address: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + }; + mockQuotes = [ + { + request: { + sourceTokenAddress: '0x0000000000000000000000000000000000000000', + sourceChainId: '0x89', + }, + }, + ]; + mockPayTotals = { total: '100' }; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.isPayFeesLoading).toBe(false); + expect(getNativeTokenAddress).toHaveBeenCalledWith('0x89'); + }); + + it('returns false when requiredTokens include selected token and quotes are unavailable', () => { + mockIsPredictBalanceSelected = false; + mockSelectedPaymentToken = { address: '0xabc', chainId: '0x1' }; + mockQuotes = null; + mockPayTotals = { total: '100' }; + mockRequiredTokens = [{ address: '0xABC', chainId: '0x1' }]; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.isPayFeesLoading).toBe(false); + }); + + it('returns false when requiredTokens include selected token but quotes are empty', () => { mockIsPredictBalanceSelected = false; mockSelectedPaymentToken = { address: '0xabc', chainId: '0x1' }; mockQuotes = []; @@ -462,4 +626,120 @@ describe('usePredictBuyConditions', () => { expect(result.current.isUserChangeTriggeringCalculation).toBe(false); }); }); + + describe('canSelectToken', () => { + it('returns true when the total exceeds predict balance', () => { + mockPredictBalance = 10; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + }), + ); + + expect(result.current.canSelectToken).toBe(true); + }); + + it('returns false when predict balance covers the total', () => { + mockPredictBalance = 20; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + }), + ); + + expect(result.current.canSelectToken).toBe(false); + }); + + it('returns true when predict balance is below the minimum bet', () => { + mockPredictBalance = 0.5; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 0, + }), + ); + + expect(result.current.canSelectToken).toBe(true); + }); + + it('returns false when predict balance equals the minimum bet and covers the total', () => { + mockPredictBalance = 1; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 1, + }), + ); + + expect(result.current.canSelectToken).toBe(false); + }); + }); + + describe('selected payment token reset effect', () => { + it('resets the selected token when predict balance covers the total and input is not focused', () => { + mockPredictBalance = 20; + mockIsPredictBalanceSelected = false; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: false, + }), + ); + + expect(mockResetSelectedPaymentToken).toHaveBeenCalledTimes(1); + }); + + it('does not reset the selected token while the input is focused', () => { + mockPredictBalance = 20; + mockIsPredictBalanceSelected = false; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: true, + }), + ); + + expect(mockResetSelectedPaymentToken).not.toHaveBeenCalled(); + }); + + it('does not reset the selected token when predict balance is already selected', () => { + mockPredictBalance = 20; + mockIsPredictBalanceSelected = true; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: false, + }), + ); + + expect(mockResetSelectedPaymentToken).not.toHaveBeenCalled(); + }); + + it('does not reset the selected token when predict balance does not cover the total', () => { + mockPredictBalance = 10; + mockIsPredictBalanceSelected = false; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: false, + }), + ); + + expect(mockResetSelectedPaymentToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts index 1e155118b6b..ea8b2c57ff3 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts @@ -1,8 +1,4 @@ -import { useMemo } from 'react'; -import { MINIMUM_BET } from '../../../constants/transactions'; -import { ActiveOrderState, OrderPreview } from '../../../types'; -import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import { useEffect, useMemo } from 'react'; import { useIsTransactionPayLoading, useIsTransactionPayQuoteLoading, @@ -10,36 +6,68 @@ import { useTransactionPayRequiredTokens, useTransactionPayTotals, } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayData'; -import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; +import { MINIMUM_BET } from '../../../constants/transactions'; import { usePredictDeposit } from '../../../hooks/usePredictDeposit'; +import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; +import { OrderPreview } from '../../../types'; +import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; +import { useInsufficientPayTokenBalanceAlert } from '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; +import { EMPTY_ADDRESS } from '../../../../../../constants/transaction'; +import { usePredictBalance } from '../../../hooks/usePredictBalance'; interface UsePredictBuyConditionsParams { currentValue: number; preview?: OrderPreview | null; isPreviewCalculating: boolean; - isPlaceOrderLoading: boolean; isUserInputChange: boolean; isConfirming: boolean; + totalPayForPredictBalance: number; + isInputFocused: boolean; } +const normalizeQuoteComparableAddress = ( + address?: string, + chainId?: string, +) => { + if (!address || !chainId) { + return address?.toLowerCase(); + } + + const nativeTokenAddress = getNativeTokenAddress(chainId as Hex); + + return address.toLowerCase() === nativeTokenAddress.toLowerCase() + ? EMPTY_ADDRESS + : address.toLowerCase(); +}; + export const usePredictBuyConditions = ({ preview, currentValue, isPreviewCalculating, - isPlaceOrderLoading, isUserInputChange, isConfirming, + totalPayForPredictBalance, + isInputFocused, }: UsePredictBuyConditionsParams) => { - const { isBalanceLoading } = usePredictBuyAvailableBalance(); - const { activeOrder } = usePredictActiveOrder(); - const payTotals = useTransactionPayTotals(); + const { isBalanceLoading, availableBalance } = + usePredictBuyAvailableBalance(); const isPayTotalsLoading = useIsTransactionPayLoading(); const isPayQuoteLoading = useIsTransactionPayQuoteLoading(); + const { isDepositPending } = usePredictDeposit(); + const payTotals = useTransactionPayTotals(); const quotes = useTransactionPayQuotes(); const requiredTokens = useTransactionPayRequiredTokens(); - const { isPredictBalanceSelected, selectedPaymentToken } = - usePredictPaymentToken(); - const { isDepositPending } = usePredictDeposit(); + const { + isPredictBalanceSelected, + selectedPaymentToken, + resetSelectedPaymentToken, + } = usePredictPaymentToken(); + const { data: predictBalance = 0 } = usePredictBalance(); + + const [insufficientPayTokenBalanceAlert] = + useInsufficientPayTokenBalanceAlert(); const shouldWaitForPayFees = !isPredictBalanceSelected; @@ -53,26 +81,46 @@ export const usePredictBuyConditions = ({ [currentValue], ); - const isRateLimited = useMemo(() => preview?.rateLimited ?? false, [preview]); - - const isDepositing = useMemo( - () => activeOrder?.state === ActiveOrderState.DEPOSITING, - [activeOrder], - ); + const maxBetAmount = useMemo(() => { + const feeRate = (preview?.fees?.totalFeePercentage ?? 0) / 100; + return Math.max( + 0, + Math.floor((availableBalance / (1 + feeRate)) * 100) / 100, + ); + }, [availableBalance, preview?.fees?.totalFeePercentage]); - const isPlacingOrder = useMemo( + const isInsufficientBalance = useMemo( () => - activeOrder?.state === ActiveOrderState.PLACING_ORDER || - isPlaceOrderLoading || - isDepositing, - [activeOrder?.state, isPlaceOrderLoading, isDepositing], + isPredictBalanceSelected && + !isConfirming && + currentValue > 0 && + currentValue > maxBetAmount, + [isConfirming, isPredictBalanceSelected, currentValue, maxBetAmount], ); - const isRedirecting = useMemo( - () => activeOrder?.state === ActiveOrderState.REDIRECTING, - [activeOrder], + const isInsufficientPayTokenBalance = useMemo( + () => !isPredictBalanceSelected && !!insufficientPayTokenBalanceAlert, + [isPredictBalanceSelected, insufficientPayTokenBalanceAlert], ); + const isRateLimited = useMemo(() => preview?.rateLimited ?? false, [preview]); + + const isPaymentTokenRequired = useMemo(() => { + if (!selectedPaymentToken || !requiredTokens?.length) { + return false; + } + return requiredTokens.some( + (token) => + normalizeQuoteComparableAddress(token.address, token.chainId) === + normalizeQuoteComparableAddress( + selectedPaymentToken.address, + selectedPaymentToken.chainId, + ) && + token.chainId.toLowerCase() === + selectedPaymentToken.chainId?.toLowerCase(), + ); + }, [selectedPaymentToken, requiredTokens]); + // Workaround: TransactionPayController sets paymentToken and isLoading in // separate state updates, causing a render with stale totals + loading=false. // Compare quote source token with selected token to bridge the gap. @@ -88,13 +136,6 @@ export const usePredictBuyConditions = ({ return false; } if (!quotes?.length) { - const isPaymentTokenRequired = requiredTokens?.some( - (token) => - token.address.toLowerCase() === - selectedPaymentToken.address?.toLowerCase() && - token.chainId.toLowerCase() === - selectedPaymentToken.chainId?.toLowerCase(), - ); return !isPaymentTokenRequired; } const request = quotes[0]?.request; @@ -102,8 +143,14 @@ export const usePredictBuyConditions = ({ return false; } return ( - request.sourceTokenAddress?.toLowerCase() !== - selectedPaymentToken.address?.toLowerCase() || + normalizeQuoteComparableAddress( + request.sourceTokenAddress, + request.sourceChainId, + ) !== + normalizeQuoteComparableAddress( + selectedPaymentToken.address, + selectedPaymentToken.chainId, + ) || request.sourceChainId?.toLowerCase() !== selectedPaymentToken.chainId?.toLowerCase() ); @@ -113,20 +160,23 @@ export const usePredictBuyConditions = ({ selectedPaymentToken, quotes, payTotals, - requiredTokens, + isPaymentTokenRequired, ]); const isPayFeesLoading = useMemo( () => - isRedirecting || - (shouldWaitForPayFees && - (isPayTotalsLoading || isPayQuoteLoading || isQuotesStale)), + shouldWaitForPayFees && + (isPayTotalsLoading || + isPayQuoteLoading || + isQuotesStale || + (quotes?.length === 0 && !payTotals)), [ - isRedirecting, shouldWaitForPayFees, isPayTotalsLoading, isPayQuoteLoading, isQuotesStale, + payTotals, + quotes?.length, ], ); @@ -134,21 +184,21 @@ export const usePredictBuyConditions = ({ () => !isConfirming && !isBelowMinimum && + !isInsufficientBalance && !!preview && - !isPlaceOrderLoading && !isRateLimited && !isBalanceLoading && - !isRedirecting && - !isPayFeesLoading, + !isPayFeesLoading && + !isInsufficientPayTokenBalance, [ isConfirming, isBelowMinimum, + isInsufficientBalance, preview, - isPlaceOrderLoading, isRateLimited, isBalanceLoading, - isRedirecting, isPayFeesLoading, + isInsufficientPayTokenBalance, ], ); @@ -157,13 +207,38 @@ export const usePredictBuyConditions = ({ [isPreviewCalculating, isUserInputChange], ); + const canSelectToken = useMemo( + () => + totalPayForPredictBalance > predictBalance || + predictBalance < MINIMUM_BET, + [predictBalance, totalPayForPredictBalance], + ); + + useEffect(() => { + if ( + !isPredictBalanceSelected && + !isInputFocused && + predictBalance >= totalPayForPredictBalance + ) { + resetSelectedPaymentToken(); + } + }, [ + isInputFocused, + isPredictBalanceSelected, + predictBalance, + resetSelectedPaymentToken, + totalPayForPredictBalance, + ]); + return { isBelowMinimum, + isInsufficientBalance, + maxBetAmount, isRateLimited, - isPlacingOrder, canPlaceBet, isUserChangeTriggeringCalculation, isPayFeesLoading, isBalancePulsing, + canSelectToken, }; }; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts new file mode 100644 index 00000000000..d907e2a1f84 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts @@ -0,0 +1,383 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { OrderPreview, Side } from '../../../types'; +import { getPlaceOrderErrorOutcome } from '../../../utils/predictErrorHandler'; +import { usePredictBuyError } from './usePredictBuyError'; + +const mockClearOrderError = jest.fn(); + +let mockActiveOrder: { error?: string } | null = null; +let mockIsBalanceLoading = false; +let mockIsPredictBalanceSelected = true; +let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null; + +jest.mock('../../../hooks/usePredictActiveOrder', () => ({ + usePredictActiveOrder: () => ({ + activeOrder: mockActiveOrder, + clearOrderError: mockClearOrderError, + }), +})); + +jest.mock('../../../hooks/usePredictBalance', () => ({ + usePredictBalance: () => ({ + data: 0, + isLoading: false, + }), +})); + +jest.mock('../../../hooks/usePredictPaymentToken', () => ({ + usePredictPaymentToken: () => ({ + isPredictBalanceSelected: mockIsPredictBalanceSelected, + }), +})); + +jest.mock( + '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert', + () => ({ + useInsufficientPayTokenBalanceAlert: () => [ + mockInsufficientPayTokenBalanceAlert, + ], + }), +); + +jest.mock( + '../../../../../Views/confirmations/hooks/pay/useTransactionPayData', + () => ({ + useTransactionPayTotals: () => null, + }), +); + +jest.mock('./usePredictBuyAvailableBalance', () => ({ + usePredictBuyAvailableBalance: () => ({ + availableBalance: 1000, + isBalanceLoading: mockIsBalanceLoading, + }), +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, options?: Record) => { + if (key === 'predict.order.prediction_minimum_bet') { + return `Minimum bet: ${options?.amount}`; + } + if (key === 'predict.order.prediction_insufficient_funds') { + return `Not enough funds. You can use up to ${options?.amount}.`; + } + if (key === 'predict.order.no_funds_enough') { + return 'Not enough funds.'; + } + return key; + }), +})); + +jest.mock('../../../utils/format', () => ({ + formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), +})); + +jest.mock('../../../constants/transactions', () => ({ + MINIMUM_BET: 1, +})); + +jest.mock('../../../utils/predictErrorHandler', () => ({ + getPlaceOrderErrorOutcome: jest.fn(), +})); + +const mockGetPlaceOrderErrorOutcome = + getPlaceOrderErrorOutcome as jest.MockedFunction< + typeof getPlaceOrderErrorOutcome + >; + +const createMockPreview = ( + overrides?: Partial, +): OrderPreview => ({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + timestamp: 1000000, + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 100, + minAmountReceived: 180, + slippage: 0.01, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + rateLimited: false, + fees: { + totalFee: 5, + metamaskFee: 2, + providerFee: 3, + totalFeePercentage: 0.05, + collector: '0xCollector', + }, + ...overrides, +}); + +const defaultParams = { + preview: createMockPreview(), + previewError: null as string | null, + isConfirming: false, + isPlacingOrder: false, + isBelowMinimum: false, + isInsufficientBalance: false, + maxBetAmount: 100, + isPayFeesLoading: false, +}; + +describe('usePredictBuyError', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActiveOrder = null; + mockIsBalanceLoading = false; + mockIsPredictBalanceSelected = true; + mockInsufficientPayTokenBalanceAlert = null; + }); + + describe('errorResult', () => { + it('returns undefined errorMessage when isBalanceLoading is true', () => { + mockIsBalanceLoading = true; + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('returns undefined errorMessage when isPlacingOrder is true', () => { + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ ...defaultParams, isPlacingOrder: true }), + ); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('returns undefined errorMessage when isConfirming is true', () => { + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ ...defaultParams, isConfirming: true }), + ); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('returns undefined errorMessage when preview is null', () => { + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ ...defaultParams, preview: null }), + ); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('calls getPlaceOrderErrorOutcome when activeOrder has error and no blocking conditions', () => { + mockActiveOrder = { error: 'order failed' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error message', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(mockGetPlaceOrderErrorOutcome).toHaveBeenCalledWith({ + error: 'order failed', + orderParams: { preview: defaultParams.preview }, + }); + expect(result.current.errorMessage).toBe('parsed error message'); + }); + + it('returns the pay token balance alert message for external payment tokens', () => { + mockActiveOrder = { error: 'order failed' }; + mockIsPredictBalanceSelected = false; + mockInsufficientPayTokenBalanceAlert = { + message: 'Insufficient payment token balance', + }; + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + expect(result.current.errorMessage).toBe( + 'Insufficient payment token balance', + ); + }); + + it('returns undefined when activeOrder has no error', () => { + mockActiveOrder = {}; + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + expect(result.current.errorMessage).toBeUndefined(); + }); + }); + + describe('errorMessage', () => { + it('returns previewError when it exists (highest priority)', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + previewError: 'Preview failed', + }), + ); + + expect(result.current.errorMessage).toBe('Preview failed'); + }); + + it('returns previewError even when other error conditions exist', () => { + mockActiveOrder = { error: 'order error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + previewError: 'Preview failed', + isBelowMinimum: true, + isInsufficientBalance: true, + }), + ); + + expect(result.current.errorMessage).toBe('Preview failed'); + }); + + it('returns minimum bet message when isBelowMinimum is true and no errorResult', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + isBelowMinimum: true, + }), + ); + + expect(result.current.errorMessage).toBe('Minimum bet: $1.00'); + }); + + it('returns insufficient funds message with formatted max when maxBetAmount >= MINIMUM_BET', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + isInsufficientBalance: true, + maxBetAmount: 50, + }), + ); + + expect(result.current.errorMessage).toBe( + 'Not enough funds. You can use up to $50.00.', + ); + }); + + it('returns generic no funds message when maxBetAmount < MINIMUM_BET', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + isInsufficientBalance: true, + maxBetAmount: 0.5, + }), + ); + + expect(result.current.errorMessage).toBe('Not enough funds.'); + }); + + it('returns undefined when no error conditions exist', () => { + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('returns undefined when errorResult status is order_not_filled', () => { + mockActiveOrder = { error: 'order not filled' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'order_not_filled', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('returns error string when errorResult status is error', () => { + mockActiveOrder = { error: 'something broke' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'Order placement failed', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBe('Order placement failed'); + }); + }); + + describe('isOrderNotFilled', () => { + it('sets to true when errorResult status is order_not_filled', () => { + mockActiveOrder = { error: 'not filled' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'order_not_filled', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(true); + }); + + it('remains false when errorResult status is error', () => { + mockActiveOrder = { error: 'something broke' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'Order placement failed', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(false); + }); + + it('remains false when no activeOrder error exists', () => { + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(false); + }); + }); + + describe('resetOrderNotFilled', () => { + it('calls clearOrderError and resets isOrderNotFilled to false', () => { + mockActiveOrder = { error: 'not filled' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'order_not_filled', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(true); + + act(() => { + result.current.resetOrderNotFilled(); + }); + + expect(mockClearOrderError).toHaveBeenCalledTimes(1); + expect(result.current.isOrderNotFilled).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts new file mode 100644 index 00000000000..a99a1a8cc55 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { strings } from '../../../../../../../locales/i18n'; +import { MINIMUM_BET } from '../../../constants/transactions'; +import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import { OrderPreview } from '../../../types'; +import { formatPrice } from '../../../utils/format'; +import { getPlaceOrderErrorOutcome } from '../../../utils/predictErrorHandler'; +import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; +import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; +import { useInsufficientPayTokenBalanceAlert } from '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert'; + +interface UsePredictBuyInfoParams { + preview?: OrderPreview | null; + previewError: string | null; + isConfirming: boolean; + isPlacingOrder: boolean; + isBelowMinimum: boolean; + isInsufficientBalance: boolean; + maxBetAmount: number; + isPayFeesLoading: boolean; +} + +export const usePredictBuyError = ({ + preview, + previewError, + isConfirming, + isPlacingOrder, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + isPayFeesLoading, +}: UsePredictBuyInfoParams) => { + const { activeOrder, clearOrderError } = usePredictActiveOrder(); + const { isBalanceLoading } = usePredictBuyAvailableBalance(); + const [isOrderNotFilled, setIsOrderNotFilled] = useState(false); + const { isPredictBalanceSelected } = usePredictPaymentToken(); + const [insufficientPayTokenBalanceAlert] = + useInsufficientPayTokenBalanceAlert(); + + const errorResult = useMemo(() => { + if (isBalanceLoading || isPlacingOrder || isConfirming || !preview) { + return undefined; + } + + if ( + !isPayFeesLoading && + !isPredictBalanceSelected && + !!insufficientPayTokenBalanceAlert + ) { + return { + status: 'error', + error: insufficientPayTokenBalanceAlert.message, + }; + } + + return activeOrder?.error + ? getPlaceOrderErrorOutcome({ + error: activeOrder?.error, + orderParams: { preview }, + }) + : undefined; + }, [ + isBalanceLoading, + isPlacingOrder, + isConfirming, + preview, + isPayFeesLoading, + isPredictBalanceSelected, + insufficientPayTokenBalanceAlert, + activeOrder?.error, + ]); + + const errorMessage = useMemo(() => { + if (previewError) { + return previewError; + } + + if (isBelowMinimum) { + return strings('predict.order.prediction_minimum_bet', { + amount: formatPrice(MINIMUM_BET, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + }); + } + + if (isInsufficientBalance) { + const formattedMax = formatPrice(maxBetAmount, { + minimumDecimals: 2, + maximumDecimals: 2, + }); + return maxBetAmount >= MINIMUM_BET + ? strings('predict.order.prediction_insufficient_funds', { + amount: formattedMax, + }) + : strings('predict.order.no_funds_enough'); + } + + if (!errorResult) { + return undefined; + } + + if (errorResult.status === 'order_not_filled') { + return undefined; + } + + if (errorResult.status === 'error') { + return errorResult.error; + } + + return undefined; + }, [ + previewError, + errorResult, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + ]); + + const resetOrderNotFilled = useCallback(() => { + clearOrderError(); + setIsOrderNotFilled(false); + }, [clearOrderError]); + + useEffect(() => { + if (errorResult?.status === 'order_not_filled') { + setIsOrderNotFilled(true); + } + }, [errorResult]); + + return { + errorMessage, + isOrderNotFilled, + resetOrderNotFilled, + }; +}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts index 3cb43022a85..566e98ecef3 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts @@ -11,6 +11,10 @@ let mockPayTotals: { }; } | null = null; let mockActiveOrder: { error?: string } | null = null; +let mockPredictBalance = 0; +let mockAvailableBalance = 1000; +let mockIsBalanceLoading = false; +let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null; jest.mock('../../../hooks/usePredictPaymentToken', () => ({ usePredictPaymentToken: () => ({ @@ -31,6 +35,52 @@ jest.mock('../../../hooks/usePredictActiveOrder', () => ({ }), })); +jest.mock('../../../hooks/usePredictBalance', () => ({ + usePredictBalance: () => ({ + data: mockPredictBalance, + isLoading: false, + }), +})); + +jest.mock('./usePredictBuyAvailableBalance', () => ({ + usePredictBuyAvailableBalance: () => ({ + availableBalance: mockAvailableBalance, + isBalanceLoading: mockIsBalanceLoading, + }), +})); + +jest.mock( + '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert', + () => ({ + useInsufficientPayTokenBalanceAlert: () => [ + mockInsufficientPayTokenBalanceAlert, + ], + }), +); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, options?: Record) => { + if (key === 'predict.order.prediction_minimum_bet') { + return `Minimum bet: ${options?.amount}`; + } + if (key === 'predict.order.prediction_insufficient_funds') { + return `Not enough funds. You can use up to ${options?.amount}.`; + } + if (key === 'predict.order.no_funds_enough') { + return 'Not enough funds.'; + } + return key; + }), +})); + +jest.mock('../../../utils/format', () => ({ + formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), +})); + +jest.mock('../../../constants/transactions', () => ({ + MINIMUM_BET: 1, +})); + const createMockPreview = ( overrides?: Partial, ): OrderPreview => ({ @@ -61,10 +111,8 @@ const defaultParams = { currentValue: 100, preview: createMockPreview(), previewError: null as string | null, - placeOrderError: null as string | null, - isOrderNotFilled: false, - isPlaceOrderLoading: false, isConfirming: false, + isPlacingOrder: false, }; describe('usePredictBuyInfo', () => { @@ -73,6 +121,10 @@ describe('usePredictBuyInfo', () => { mockIsPredictBalanceSelected = true; mockPayTotals = null; mockActiveOrder = null; + mockPredictBalance = 0; + mockAvailableBalance = 1000; + mockIsBalanceLoading = false; + mockInsufficientPayTokenBalanceAlert = null; }); describe('depositFee', () => { @@ -127,6 +179,76 @@ describe('usePredictBuyInfo', () => { expect(result.current.depositFee).toBe(2.0); }); + + it('returns 0 when there is an insufficient pay token balance alert', () => { + mockIsPredictBalanceSelected = false; + mockPayTotals = { + fees: { + provider: { usd: 1.5 }, + sourceNetwork: { estimate: { usd: 2.5 } }, + targetNetwork: { usd: 1.0 }, + }, + }; + mockInsufficientPayTokenBalanceAlert = { + message: 'Insufficient payment token balance', + }; + + const { result } = renderHook(() => usePredictBuyInfo(defaultParams)); + + expect(result.current.depositFee).toBe(0); + }); + + it('keeps the last accepted deposit fee while confirming', () => { + mockIsPredictBalanceSelected = false; + mockPayTotals = { + fees: { + provider: { usd: 1.5 }, + sourceNetwork: { estimate: { usd: 2.5 } }, + targetNetwork: { usd: 1.0 }, + }, + }; + + const { result, rerender } = renderHook( + (params: typeof defaultParams) => usePredictBuyInfo(params), + { + initialProps: { ...defaultParams, isConfirming: true }, + }, + ); + + expect(result.current.depositFee).toBe(5); + + mockPayTotals = {}; + + rerender({ ...defaultParams, isConfirming: true }); + + expect(result.current.depositFee).toBe(5); + }); + + it('clears the accepted deposit fee after confirming ends', () => { + mockIsPredictBalanceSelected = false; + mockPayTotals = { + fees: { + provider: { usd: 1.5 }, + sourceNetwork: { estimate: { usd: 2.5 } }, + targetNetwork: { usd: 1.0 }, + }, + }; + + const { result, rerender } = renderHook( + (params: typeof defaultParams) => usePredictBuyInfo(params), + { + initialProps: { ...defaultParams, isConfirming: true }, + }, + ); + + expect(result.current.depositFee).toBe(5); + + mockPayTotals = {}; + + rerender({ ...defaultParams, isConfirming: false }); + + expect(result.current.depositFee).toBe(0); + }); }); describe('total', () => { @@ -205,18 +327,26 @@ describe('usePredictBuyInfo', () => { }); }); - describe('rewardsFeeAmount', () => { - it('returns totalFee from preview fees', () => { + describe('depositAmount', () => { + it('returns the remaining amount needed after predict balance is applied', () => { + mockPredictBalance = 80; + + const { result } = renderHook(() => usePredictBuyInfo(defaultParams)); + + expect(result.current.depositAmount).toBe(25); + }); + + it('rounds the remaining amount up to 2 decimals when a deposit is still needed', () => { + mockPredictBalance = 0; const params = { ...defaultParams, - isPlaceOrderLoading: false, - previewError: null, + currentValue: 2, preview: createMockPreview({ fees: { - totalFee: 7, - metamaskFee: 3, - providerFee: 4, - totalFeePercentage: 0.07, + totalFee: 0.075, + metamaskFee: 0.035, + providerFee: 0.04, + totalFeePercentage: 4, collector: '0xCollector', }, }), @@ -224,111 +354,126 @@ describe('usePredictBuyInfo', () => { const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.rewardsFeeAmount).toBe(7); + expect(result.current.depositAmount).toBe(2.08); }); - it('returns undefined when isPlaceOrderLoading is true', () => { + it('rounds up even when the third decimal is below 5 so the deposit fully covers the shortfall', () => { + mockPredictBalance = 0; const params = { ...defaultParams, - isPlaceOrderLoading: true, - previewError: null, - }; - - const { result } = renderHook(() => usePredictBuyInfo(params)); - - expect(result.current.rewardsFeeAmount).toBeUndefined(); - }); - - it('returns undefined when previewError exists', () => { - const params = { - ...defaultParams, - isPlaceOrderLoading: false, - previewError: 'Preview failed', + currentValue: 2, + preview: createMockPreview({ + fees: { + totalFee: 0.074, + metamaskFee: 0.034, + providerFee: 0.04, + totalFeePercentage: 4, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.rewardsFeeAmount).toBeUndefined(); + expect(result.current.depositAmount).toBe(2.08); }); - }); - describe('errorMessage', () => { - it('returns undefined when isOrderNotFilled is true', () => { + it('rounds a tiny positive shortfall up to the minimum cent instead of zero', () => { + mockPredictBalance = 2.075889; const params = { ...defaultParams, - isOrderNotFilled: true, - previewError: 'Some error', - placeOrderError: 'Place error', + currentValue: 2, + preview: createMockPreview({ + fees: { + totalFee: 0.08, + metamaskFee: 0.04, + providerFee: 0.04, + totalFeePercentage: 4, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.depositAmount).toBe(0.01); }); - it('returns undefined when isConfirming is true', () => { + it('returns the full preview total when predict balance already covers the bet', () => { + mockPredictBalance = 110; const params = { ...defaultParams, - isConfirming: true, - previewError: 'Some error', - placeOrderError: 'Place error', + currentValue: 1, + preview: createMockPreview({ + maxAmountSpent: 1, + fees: { + totalFee: 0.04, + metamaskFee: 0.02, + providerFee: 0.02, + totalFeePercentage: 4, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.depositAmount).toBe(1.04); }); + }); - it('returns previewError as priority error', () => { - mockActiveOrder = { error: 'Active order error' }; - const params = { - ...defaultParams, - previewError: 'Preview error', - placeOrderError: 'Place order error', - }; - - const { result } = renderHook(() => usePredictBuyInfo(params)); + describe('totalPayForPredictBalance', () => { + it('returns the bet amount plus provider and MetaMask fees', () => { + const { result } = renderHook(() => usePredictBuyInfo(defaultParams)); - expect(result.current.errorMessage).toBe('Preview error'); + expect(result.current.totalPayForPredictBalance).toBe(105); }); + }); - it('returns placeOrderError when no previewError', () => { - mockActiveOrder = { error: 'Active order error' }; + describe('rewardsFeeAmount', () => { + it('returns totalFee from preview fees', () => { const params = { ...defaultParams, + isPlacingOrder: false, previewError: null, - placeOrderError: 'Place order error', + preview: createMockPreview({ + fees: { + totalFee: 7, + metamaskFee: 3, + providerFee: 4, + totalFeePercentage: 0.07, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBe('Place order error'); + expect(result.current.rewardsFeeAmount).toBe(7); }); - it('returns activeOrder.error as fallback', () => { - mockActiveOrder = { error: 'Active order error' }; + it('returns undefined when isPlacingOrder is true', () => { const params = { ...defaultParams, + isPlacingOrder: true, previewError: null, - placeOrderError: null, }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBe('Active order error'); + expect(result.current.rewardsFeeAmount).toBeUndefined(); }); - it('returns undefined when no errors exist', () => { - mockActiveOrder = null; + it('returns undefined when previewError exists', () => { const params = { ...defaultParams, - previewError: null, - placeOrderError: null, + isPlacingOrder: false, + previewError: 'Preview failed', }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.rewardsFeeAmount).toBeUndefined(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts index 9b0533f701e..5bf8151e987 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts @@ -1,46 +1,79 @@ import { BigNumber } from 'bignumber.js'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTransactionPayTotals } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayData'; -import { OrderPreview } from '../../../types'; +import { usePredictBalance } from '../../../hooks/usePredictBalance'; import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import { OrderPreview } from '../../../types'; +import { useInsufficientPayTokenBalanceAlert } from '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert'; interface UsePredictBuyInfoParams { currentValue: number; preview?: OrderPreview | null; previewError: string | null; - placeOrderError?: string | null; - isOrderNotFilled: boolean; - isPlaceOrderLoading: boolean; isConfirming: boolean; + isPlacingOrder: boolean; } export const usePredictBuyInfo = ({ preview, previewError, currentValue, - placeOrderError, - isOrderNotFilled, - isPlaceOrderLoading, isConfirming, + isPlacingOrder, }: UsePredictBuyInfoParams) => { const { isPredictBalanceSelected } = usePredictPaymentToken(); const payTotals = useTransactionPayTotals(); - const { activeOrder } = usePredictActiveOrder(); + const { data: predictBalance = 0 } = usePredictBalance(); + + const [insufficientPayTokenBalanceAlert] = + useInsufficientPayTokenBalanceAlert(); + + const [acceptedDepositFee, setAcceptedDepositFee] = useState(0); - const depositFee = useMemo(() => { - if (isPredictBalanceSelected || !payTotals?.fees) return 0; + const totalPayForPredictBalance = useMemo( + () => + currentValue + + (preview?.fees?.providerFee ?? 0) + + (preview?.fees?.metamaskFee ?? 0), + [currentValue, preview?.fees?.providerFee, preview?.fees?.metamaskFee], + ); + + const computedDepositFee = useMemo(() => { + if ( + isPredictBalanceSelected || + !payTotals?.fees || + insufficientPayTokenBalanceAlert + ) + return 0; const { provider, sourceNetwork, targetNetwork } = payTotals.fees; return new BigNumber(provider?.usd ?? 0) .plus(sourceNetwork?.estimate?.usd ?? 0) .plus(targetNetwork?.usd ?? 0) .toNumber(); - }, [isPredictBalanceSelected, payTotals]); + }, [ + insufficientPayTokenBalanceAlert, + isPredictBalanceSelected, + payTotals?.fees, + ]); + + useEffect(() => { + if (computedDepositFee > 0) { + setAcceptedDepositFee(computedDepositFee); + } + }, [computedDepositFee]); + + useEffect(() => { + if (!isConfirming) { + setAcceptedDepositFee(0); + } + }, [isConfirming]); + + const fallbackDepositFee = isConfirming ? acceptedDepositFee : 0; + const depositFee = + computedDepositFee > 0 ? computedDepositFee : fallbackDepositFee; const rewardsFeeAmount = - isPlaceOrderLoading || previewError - ? undefined - : (preview?.fees?.totalFee ?? 0); + isPlacingOrder || previewError ? undefined : (preview?.fees?.totalFee ?? 0); const { toWin, metamaskFee, providerFee, total } = useMemo( () => ({ @@ -48,43 +81,39 @@ export const usePredictBuyInfo = ({ isRateLimited: preview?.rateLimited ?? false, metamaskFee: preview?.fees?.metamaskFee ?? 0, providerFee: preview?.fees?.providerFee ?? 0, - total: - currentValue + - (preview?.fees?.providerFee ?? 0) + - (preview?.fees?.metamaskFee ?? 0) + - depositFee, + total: totalPayForPredictBalance + depositFee, }), [ - currentValue, depositFee, preview?.fees?.metamaskFee, preview?.fees?.providerFee, preview?.minAmountReceived, preview?.rateLimited, + totalPayForPredictBalance, ], ); - const errorMessage = useMemo( - () => - isOrderNotFilled || isConfirming - ? undefined - : (previewError ?? placeOrderError ?? activeOrder?.error), - [ - isOrderNotFilled, - isConfirming, - previewError, - placeOrderError, - activeOrder?.error, - ], - ); + const depositAmount = useMemo(() => { + const remainingAmount = new BigNumber(totalPayForPredictBalance) + .minus(predictBalance) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + if (remainingAmount <= 0) { + return new BigNumber(totalPayForPredictBalance) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + } + return remainingAmount; + }, [predictBalance, totalPayForPredictBalance]); return { toWin, metamaskFee, providerFee, depositFee, + depositAmount, total, rewardsFeeAmount, - errorMessage, + totalPayForPredictBalance, }; }; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts index 818be72b297..43c1a909ecc 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts @@ -1,14 +1,11 @@ import { renderHook, act } from '@testing-library/react-native'; import { usePredictBuyInputState } from './usePredictBuyInputState'; -let mockActiveOrder: { amount?: number; isInputFocused?: boolean } | null = - null; -const mockUpdateActiveOrder = jest.fn(); +const mockClearOrderError = jest.fn(); jest.mock('../../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - activeOrder: mockActiveOrder, - updateActiveOrder: mockUpdateActiveOrder, + clearOrderError: mockClearOrderError, }), })); @@ -24,29 +21,10 @@ jest.mock('@react-navigation/native', () => ({ describe('usePredictBuyInputState', () => { beforeEach(() => { jest.clearAllMocks(); - mockActiveOrder = null; }); describe('currentValue', () => { - it('returns amount from activeOrder when set', () => { - mockActiveOrder = { amount: 42 }; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.currentValue).toBe(42); - }); - - it('returns 0 when activeOrder is null', () => { - mockActiveOrder = null; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.currentValue).toBe(0); - }); - - it('returns 0 when activeOrder.amount is undefined', () => { - mockActiveOrder = {}; - + it('initializes to 0', () => { const { result } = renderHook(() => usePredictBuyInputState()); expect(result.current.currentValue).toBe(0); @@ -54,23 +32,17 @@ describe('usePredictBuyInputState', () => { }); describe('setCurrentValue', () => { - it('calls updateActiveOrder with new amount', () => { - mockActiveOrder = { amount: 10 }; - + it('updates currentValue to the given number', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { result.current.setCurrentValue(20); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ amount: 20 }), - ); + expect(result.current.currentValue).toBe(20); }); it('sets isUserInputChange to true when value changes and is greater than 0', () => { - mockActiveOrder = { amount: 5 }; - const { result } = renderHook(() => usePredictBuyInputState()); act(() => { @@ -80,76 +52,63 @@ describe('usePredictBuyInputState', () => { expect(result.current.isUserInputChange).toBe(true); }); - it('clears error when user input detected (amount > 0 and changed)', () => { - mockActiveOrder = { amount: 5 }; - + it('calls clearOrderError when user input detected (amount > 0 and changed)', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { result.current.setCurrentValue(10); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ amount: 10, error: null }), - ); + expect(mockClearOrderError).toHaveBeenCalled(); }); it('handles updater function (receives previous value)', () => { - mockActiveOrder = { amount: 5 }; - const { result } = renderHook(() => usePredictBuyInputState()); + act(() => { + result.current.setCurrentValue(5); + }); + act(() => { result.current.setCurrentValue((prev) => prev + 5); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ amount: 10 }), - ); + expect(result.current.currentValue).toBe(10); + expect(mockClearOrderError).toHaveBeenCalled(); }); - it('does not set isUserInputChange when value set to 0', () => { - mockActiveOrder = { amount: 5 }; - + it('does not call clearOrderError when value set to 0', () => { const { result } = renderHook(() => usePredictBuyInputState()); + act(() => { + result.current.setCurrentValue(5); + }); + mockClearOrderError.mockClear(); + act(() => { result.current.setCurrentValue(0); }); + expect(mockClearOrderError).not.toHaveBeenCalled(); expect(result.current.isUserInputChange).toBe(false); }); }); describe('isInputFocused', () => { - it('returns isInputFocused from activeOrder', () => { - mockActiveOrder = { isInputFocused: true }; - + it('initializes to true', () => { const { result } = renderHook(() => usePredictBuyInputState()); expect(result.current.isInputFocused).toBe(true); }); - it('returns false when activeOrder is null', () => { - mockActiveOrder = null; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.isInputFocused).toBe(false); - }); - - it('setIsInputFocused calls updateActiveOrder with isInputFocused', () => { - mockActiveOrder = { isInputFocused: false }; - + it('updates isInputFocused via setIsInputFocused', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { - result.current.setIsInputFocused(true); + result.current.setIsInputFocused(false); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - isInputFocused: true, - }); + expect(result.current.isInputFocused).toBe(false); }); }); @@ -173,19 +132,9 @@ describe('usePredictBuyInputState', () => { describe('currentValueUSDString', () => { it('initializes as empty string when currentValue is 0', () => { - mockActiveOrder = null; - const { result } = renderHook(() => usePredictBuyInputState()); expect(result.current.currentValueUSDString).toBe(''); }); - - it('initializes as string representation when currentValue exists', () => { - mockActiveOrder = { amount: 25 }; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.currentValueUSDString).toBe('25'); - }); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts index ccd9406636c..a65dd005822 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts @@ -1,18 +1,10 @@ -import { SetStateAction, useCallback, useMemo, useRef, useState } from 'react'; - +import { SetStateAction, useCallback, useRef, useState } from 'react'; import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -import { RouteProp, useRoute } from '@react-navigation/native'; -import { PredictNavigationParamList } from '../../../types/navigation'; export const usePredictBuyInputState = () => { - const { activeOrder, updateActiveOrder } = usePredictActiveOrder(); - - const route = - useRoute>(); - - const { isConfirming: initialIsConfirmingFromRoute = false } = route.params; + const { clearOrderError } = usePredictActiveOrder(); - const currentValue = activeOrder?.amount ?? 0; + const [currentValue, setCurrentValueState] = useState(0); const currentValueRef = useRef(currentValue); currentValueRef.current = currentValue; @@ -21,24 +13,18 @@ export const usePredictBuyInputState = () => { currentValue ? currentValue.toString() : '', ); - const isInputFocused = useMemo( - () => activeOrder?.isInputFocused ?? false, - [activeOrder], - ); + const [isInputFocused, setIsInputFocusedState] = useState(true); + const shouldSyncCurrentValueRef = useRef(false); + const shouldClearAmountErrorRef = useRef(false); + const shouldSyncInputFocusRef = useRef(false); - const setIsInputFocused = useCallback( - (_isInputFocused: boolean) => { - updateActiveOrder({ - isInputFocused: _isInputFocused, - }); - }, - [updateActiveOrder], - ); + const setIsInputFocused = useCallback((nextIsInputFocused: boolean) => { + shouldSyncInputFocusRef.current = true; + setIsInputFocusedState(nextIsInputFocused); + }, []); const [isUserInputChange, setIsUserInputChange] = useState(false); - const [isConfirming, setIsConfirming] = useState( - initialIsConfirmingFromRoute, - ); + const [isConfirming, setIsConfirming] = useState(false); const setCurrentValue = useCallback( (value: SetStateAction) => { @@ -50,16 +36,19 @@ export const usePredictBuyInputState = () => { const isUserInput = nextValue !== previousValue && nextValue > 0; + if (isUserInput) { + clearOrderError(); + } + if (nextValue !== previousValue) { setIsUserInputChange(isUserInput); } - updateActiveOrder({ - amount: nextValue, - ...(isUserInput ? { error: null } : {}), - }); + shouldSyncCurrentValueRef.current = true; + shouldClearAmountErrorRef.current = isUserInput; + setCurrentValueState(nextValue); }, - [updateActiveOrder], + [clearOrderError], ); return { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts deleted file mode 100644 index d59c598d8c0..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { renderHook, act } from '@testing-library/react-native'; -import { StackActions } from '@react-navigation/native'; -import { usePredictBuyActions } from './usePredictBuyPreviewActions'; -import { - ActiveOrderState, - OrderPreview, - PlaceOrderParams, -} from '../../../types'; -import { PREDICT_BALANCE_TOKEN_KEY } from '../../../constants/transactions'; -import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; - -const mockNavigate = jest.fn(); -const mockDispatch = jest.fn(); -const mockOnReject = jest.fn(); -const mockOnApprovalConfirm = jest.fn(); -const mockTriggerPayWithAnyToken = jest.fn(); -const mockUpdateActiveOrder = jest.fn(); -const mockClearActiveOrder = jest.fn(); -const mockNavigateToBuyPreview = jest.fn(); -const mockResetSelectedPaymentToken = jest.fn(); -let mockActiveOrder: { batchId?: string | null } | null = null; -let mockRouteParams: Record = {}; - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - dispatch: mockDispatch, - }), - useRoute: () => ({ - params: mockRouteParams, - }), -})); - -jest.mock('../../../hooks/usePredictNavigation', () => ({ - usePredictNavigation: () => ({ - navigateToBuyPreview: mockNavigateToBuyPreview, - }), -})); - -jest.mock('../../../../../Views/confirmations/hooks/useConfirmActions', () => ({ - useConfirmActions: () => ({ - onReject: mockOnReject, - }), -})); - -jest.mock( - '../../../../../Views/confirmations/hooks/useApprovalRequest', - () => ({ - __esModule: true, - default: () => ({ - onConfirm: mockOnApprovalConfirm, - }), - }), -); - -jest.mock('../../../hooks/usePredictPayWithAnyToken', () => ({ - usePredictPayWithAnyToken: () => ({ - triggerPayWithAnyToken: mockTriggerPayWithAnyToken, - }), -})); - -jest.mock('../../../hooks/usePredictActiveOrder', () => ({ - usePredictActiveOrder: () => ({ - activeOrder: mockActiveOrder, - updateActiveOrder: mockUpdateActiveOrder, - clearActiveOrder: mockClearActiveOrder, - }), -})); - -jest.mock('../../../hooks/usePredictPaymentToken', () => ({ - usePredictPaymentToken: () => ({ - resetSelectedPaymentToken: mockResetSelectedPaymentToken, - }), -})); - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -const mockPlaceOrder = jest.fn< - Promise, - [PlaceOrderParams] ->(); -const mockSetIsConfirming = jest.fn(); - -const defaultRouteParams = { - market: { id: 'market-1' }, - outcome: { id: 'outcome-1' }, - outcomeToken: { id: 'token-1' }, - entryPoint: 'predict_feed', - isConfirmation: false, - preview: null, -}; - -const createDefaultParams = (): Parameters[0] => ({ - currentValue: 100, - preview: { - minAmountReceived: 180, - fees: { totalFee: 5 }, - } as OrderPreview, - analyticsProperties: { marketId: 'market-1' }, - placeOrder: mockPlaceOrder, - depositAmount: 0, - setIsConfirming: mockSetIsConfirming, -}); - -describe('usePredictBuyActions', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockActiveOrder = null; - mockRouteParams = { ...defaultRouteParams }; - }); - - describe('handleBack', () => { - it('calls clearActiveOrder', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBack(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - }); - - it('dispatches StackActions.pop', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBack(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); - }); - }); - - describe('handleBackSwipe', () => { - it('calls clearActiveOrder', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBackSwipe(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - }); - - it('dispatches StackActions.pop when not in confirmation mode', () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBackSwipe(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); - }); - - it('does not dispatch pop when in confirmation mode', () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBackSwipe(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - }); - - describe('handleTokenSelected', () => { - it('clears error on active order', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ tokenKey: 'some-token' }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ error: null }); - }); - - it('triggers payWithAnyToken for non-predict-balance token when not in confirmation', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: 'other-token', - }); - }); - - expect(mockTriggerPayWithAnyToken).toHaveBeenCalledWith( - expect.objectContaining({ - market: defaultRouteParams.market, - outcome: defaultRouteParams.outcome, - outcomeToken: defaultRouteParams.outcomeToken, - }), - ); - }); - - it('sets state to PAY_WITH_ANY_TOKEN for non-predict-balance token', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: 'other-token', - }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - }); - }); - - it('sets state to PREVIEW for predict-balance token in confirmation mode', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: PREDICT_BALANCE_TOKEN_KEY, - }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - }); - - it('calls onReject in confirmation mode for predict-balance token', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: PREDICT_BALANCE_TOKEN_KEY, - }); - }); - - expect(mockOnReject).toHaveBeenCalledWith(undefined, true); - }); - - it('does not trigger payWithAnyToken for predict-balance token in non-confirmation', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: PREDICT_BALANCE_TOKEN_KEY, - }); - }); - - expect(mockTriggerPayWithAnyToken).not.toHaveBeenCalled(); - }); - - it('returns early for non-predict-balance token in confirmation mode', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: 'other-token', - }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ error: null }); - expect(mockOnReject).not.toHaveBeenCalled(); - expect(mockTriggerPayWithAnyToken).not.toHaveBeenCalled(); - }); - }); - - describe('handleConfirm', () => { - it('sets isConfirming to true', async () => { - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(true); - }); - - it('clears error on active order', async () => { - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ error: null }); - }); - - it('calls placeOrder with preview and analyticsProperties when not in confirmation', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const params = createDefaultParams(); - const { result } = renderHook(() => usePredictBuyActions(params)); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockPlaceOrder).toHaveBeenCalledWith({ - preview: params.preview, - analyticsProperties: params.analyticsProperties, - }); - }); - - it('sets state to PLACING_ORDER before calling placeOrder', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - const placingOrderCall = mockUpdateActiveOrder.mock.calls.find( - (call: [Record]) => - call[0].state === ActiveOrderState.PLACING_ORDER, - ); - expect(placingOrderCall).toBeDefined(); - }); - - it('sets state back to PREVIEW on placeOrder error', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ - status: 'error', - error: 'Order failed', - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('sets state back to PREVIEW on order_not_filled', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ status: 'order_not_filled' }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('calls onApprovalConfirm when isConfirmation is true', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - mockOnApprovalConfirm.mockResolvedValue(undefined); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockOnApprovalConfirm).toHaveBeenCalledWith({ - deleteAfterResult: true, - waitForResult: true, - handleErrors: false, - }); - expect(mockPlaceOrder).not.toHaveBeenCalled(); - }); - - it('resets state to PREVIEW on deposit_required', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ status: 'deposit_required' }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('resets state to PREVIEW on deposit_in_progress', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ status: 'deposit_in_progress' }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('throws error when no preview available', async () => { - mockRouteParams = { - ...defaultRouteParams, - isConfirmation: false, - preview: null, - }; - const params = createDefaultParams(); - params.preview = undefined; - const { result } = renderHook(() => usePredictBuyActions(params)); - - await expect( - act(async () => { - await result.current.handleConfirm(); - }), - ).rejects.toThrow('Preview is required'); - }); - }); - - describe('handleDepositFailed', () => { - it('sets isConfirming to false', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed('Deposit failed'); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('updates active order with error and PAY_WITH_ANY_TOKEN state', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed('Deposit failed'); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - error: 'Deposit failed', - batchId: null, - }); - }); - - it('triggers payWithAnyToken flow', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed(); - }); - - expect(mockTriggerPayWithAnyToken).toHaveBeenCalledWith({ - market: defaultRouteParams.market, - outcome: defaultRouteParams.outcome, - outcomeToken: defaultRouteParams.outcomeToken, - }); - }); - - it('clears batchId on failure', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed('error'); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ batchId: null }), - ); - }); - - it('uses default error message when none provided', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'predict.deposit.error_description', - }), - ); - }); - }); - - describe('handlePlaceOrderSuccess', () => { - it('sets isConfirming to false', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderSuccess(); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('clears active order', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderSuccess(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - }); - - it('dispatches StackActions.pop', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderSuccess(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); - }); - }); - - describe('handlePlaceOrderError', () => { - it('sets isConfirming to false', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderError(); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('sets state back to PREVIEW', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderError(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - }); - - it('resets selected payment token', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderError(); - }); - - expect(mockResetSelectedPaymentToken).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts deleted file mode 100644 index 7cd1e90a914..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - RouteProp, - StackActions, - useNavigation, - useRoute, -} from '@react-navigation/native'; -import { PredictNavigationParamList } from '../../../types/navigation'; -import { useCallback, useMemo, useState } from 'react'; -import { usePredictNavigation } from '../../../hooks/usePredictNavigation'; -import { useConfirmActions } from '../../../../../Views/confirmations/hooks/useConfirmActions'; -import { usePredictPayWithAnyToken } from '../../../hooks/usePredictPayWithAnyToken'; -import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; -import { - ActiveOrderState, - OrderPreview, - PlaceOrderParams, -} from '../../../types'; -import { strings } from '../../../../../../../locales/i18n'; -import useApprovalRequest from '../../../../../Views/confirmations/hooks/useApprovalRequest'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -import { PREDICT_BALANCE_TOKEN_KEY } from '../../../constants/transactions'; -import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; - -interface UsePredictBuyActionsParams { - currentValue: number; - preview?: OrderPreview | null; - analyticsProperties: PlaceOrderParams['analyticsProperties']; - placeOrder: (params: PlaceOrderParams) => Promise; - depositAmount: number; - setIsConfirming: (value: boolean) => void; -} - -export const usePredictBuyActions = ({ - preview: livePreview, - analyticsProperties, - placeOrder, - setIsConfirming, -}: UsePredictBuyActionsParams) => { - const route = - useRoute>(); - const navigation = useNavigation(); - const { onReject } = useConfirmActions(); - const { onConfirm: onApprovalConfirm } = useApprovalRequest(); - const { triggerPayWithAnyToken } = usePredictPayWithAnyToken(); - const { updateActiveOrder, clearActiveOrder } = usePredictActiveOrder(); - const { navigateToBuyPreview } = usePredictNavigation(); - const [isPreviewFromRouteUsed, setIsPreviewFromRouteUsed] = useState(false); - const { resetSelectedPaymentToken } = usePredictPaymentToken(); - const { activeOrder } = usePredictActiveOrder(); - - const batchId = useMemo(() => activeOrder?.batchId, [activeOrder?.batchId]); - - const { - market, - outcome, - outcomeToken, - entryPoint, - isConfirmation, - preview: previewFromRoute, - } = route.params; - - const redirectToBuyPreview = useCallback( - (params?: { includeTransaction?: boolean; isConfirming?: boolean }) => { - navigateToBuyPreview( - { - market, - outcome, - outcomeToken, - ...(livePreview ? { preview: { ...livePreview } } : {}), - animationEnabled: false, - entryPoint, - ...(params?.isConfirming ? { isConfirming: true } : {}), - }, - { replace: true }, - ); - }, - [ - entryPoint, - market, - navigateToBuyPreview, - outcome, - outcomeToken, - livePreview, - ], - ); - - const handleTokenSelected = useCallback( - async ({ tokenKey }: { tokenKey: string | null }) => { - updateActiveOrder({ error: null }); - if (isConfirmation) { - if (tokenKey !== PREDICT_BALANCE_TOKEN_KEY) { - return; - } - updateActiveOrder({ - state: ActiveOrderState.PREVIEW, - }); - redirectToBuyPreview(); - onReject(undefined, true); - return; - } - if (tokenKey !== PREDICT_BALANCE_TOKEN_KEY) { - updateActiveOrder({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - }); - triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - ...(livePreview ? { preview: { ...livePreview } } : {}), - }); - } - }, - [ - isConfirmation, - market, - onReject, - outcome, - outcomeToken, - livePreview, - redirectToBuyPreview, - triggerPayWithAnyToken, - updateActiveOrder, - ], - ); - - const handleDepositFailed = useCallback( - async (depositErrorMessage?: string) => { - setIsConfirming(false); - updateActiveOrder({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - error: - depositErrorMessage ?? strings('predict.deposit.error_description'), - batchId: null, - }); - triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - }); - }, - [ - setIsConfirming, - updateActiveOrder, - triggerPayWithAnyToken, - market, - outcome, - outcomeToken, - ], - ); - - const handleConfirm = useCallback(async () => { - setIsConfirming(true); - updateActiveOrder({ error: null }); - - if (isConfirmation) { - updateActiveOrder({ - state: ActiveOrderState.DEPOSITING, - }); - redirectToBuyPreview({ - isConfirming: true, - }); - await onApprovalConfirm({ - deleteAfterResult: true, - waitForResult: true, - handleErrors: false, - }); - return; - } - - if (!livePreview && !previewFromRoute) { - throw new Error('Preview is required'); - } - - const isFromPayWithAnyToken = batchId && !isPreviewFromRouteUsed; - const previewToUse = isFromPayWithAnyToken ? previewFromRoute : livePreview; - - if (!previewToUse) { - throw new Error('Preview is required'); - } - - if (isFromPayWithAnyToken) { - resetSelectedPaymentToken(); - setIsPreviewFromRouteUsed(true); - } - - updateActiveOrder({ - state: ActiveOrderState.PLACING_ORDER, - }); - const orderResult = await placeOrder({ - analyticsProperties, - preview: previewToUse, - }); - if ( - orderResult.status === 'error' || - orderResult.status === 'order_not_filled' || - orderResult.status === 'deposit_required' || - orderResult.status === 'deposit_in_progress' - ) { - setIsConfirming(false); - updateActiveOrder({ state: ActiveOrderState.PREVIEW }); - } - }, [ - setIsConfirming, - updateActiveOrder, - isConfirmation, - livePreview, - previewFromRoute, - batchId, - isPreviewFromRouteUsed, - placeOrder, - analyticsProperties, - redirectToBuyPreview, - onApprovalConfirm, - resetSelectedPaymentToken, - ]); - - const handleBack = useCallback(() => { - clearActiveOrder(); - navigation.dispatch(StackActions.pop()); - }, [clearActiveOrder, navigation]); - - const handleBackSwipe = useCallback(() => { - clearActiveOrder(); - if (isConfirmation) return; - navigation.dispatch(StackActions.pop()); - }, [clearActiveOrder, isConfirmation, navigation]); - - const handlePlaceOrderSuccess = useCallback(() => { - setIsConfirming(false); - clearActiveOrder(); - navigation.dispatch(StackActions.pop()); - }, [setIsConfirming, clearActiveOrder, navigation]); - - const handlePlaceOrderError = useCallback(() => { - setIsConfirming(false); - updateActiveOrder({ state: ActiveOrderState.PREVIEW }); - resetSelectedPaymentToken(); - }, [setIsConfirming, updateActiveOrder, resetSelectedPaymentToken]); - - return { - handleBack, - handleBackSwipe, - handleTokenSelected, - handleConfirm, - handleDepositFailed, - handlePlaceOrderSuccess, - handlePlaceOrderError, - }; -}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts deleted file mode 100644 index 2e7df9b9ab4..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { usePredictOrderTracking } from './usePredictOrderTracking'; -import { Result } from '../../../types'; - -describe('usePredictOrderTracking', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('onSuccess callback', () => { - it('calls onSuccess when result.success is true', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: true, response: undefined } as Result; - - // Act - renderHook(() => - usePredictOrderTracking({ - result, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); - }); - - it('does not call onSuccess when result is null', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('does not call onSuccess when result.success is false', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: false, error: 'test' } as Result; - - // Act - renderHook(() => - usePredictOrderTracking({ - result, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('calls onSuccess only once on rerender with same result', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: true, response: undefined } as Result; - - // Act - const { rerender } = renderHook( - ({ result: r, onSuccess: os, error: e, onError: oe }) => - usePredictOrderTracking({ - result: r, - onSuccess: os, - error: e, - onError: oe, - }), - { - initialProps: { - result, - onSuccess, - error: undefined, - onError, - }, - }, - ); - - rerender({ - result, - onSuccess, - error: undefined, - onError, - }); - - // Assert - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - describe('onError callback', () => { - it('calls onError when error is truthy string', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const error = 'Something went wrong'; - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error, - onError, - }), - ); - - // Assert - expect(onError).toHaveBeenCalledWith(error); - expect(onError).toHaveBeenCalledTimes(1); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('does not call onError when error is undefined', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onError).not.toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('calls onError only once on rerender with same error', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const error = 'Network error'; - - // Act - const { rerender } = renderHook( - ({ result: r, onSuccess: os, error: e, onError: oe }) => - usePredictOrderTracking({ - result: r, - onSuccess: os, - error: e, - onError: oe, - }), - { - initialProps: { - result: null, - onSuccess, - error, - onError, - }, - }, - ); - - rerender({ - result: null, - onSuccess, - error, - onError, - }); - - // Assert - expect(onError).toHaveBeenCalledTimes(1); - }); - }); - - describe('combined scenarios', () => { - it('calls both onSuccess and onError when both result.success and error are present', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: true, response: undefined } as Result; - const error = 'Error occurred'; - - // Act - renderHook(() => - usePredictOrderTracking({ - result, - onSuccess, - error, - onError, - }), - ); - - // Assert - expect(onSuccess).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(error); - expect(onError).toHaveBeenCalledTimes(1); - }); - - it('does not call either callback when result is null and error is undefined', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts deleted file mode 100644 index bdebe825272..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from 'react'; -import { Result } from '../../../types'; - -export function usePredictOrderTracking({ - result, - onSuccess, - error, - onError, -}: { - result: Result | null; - error: string | undefined; - onSuccess: () => void; - onError: (error: string) => void; -}) { - useEffect(() => { - if (result?.success) { - onSuccess(); - } - }, [onSuccess, result]); - - useEffect(() => { - if (error) { - onError(error); - } - }, [onError, error]); -} diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts deleted file mode 100644 index 394340b1e6e..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - TransactionMeta, - TransactionStatus, -} from '@metamask/transaction-controller'; -import { renderHookWithProvider } from '../../../../../../util/test/renderWithProvider'; -import { usePredictPayWithAnyTokenTracking } from './usePredictPayWithAnyTokenTracking'; -import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../../../constants/transactions'; - -let mockActiveOrder: { batchId?: string; error?: string } | null = null; -let mockRouteParams: Record = { isConfirmation: false }; - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useRoute: () => ({ - params: mockRouteParams, - }), -})); - -jest.mock('../../../hooks/usePredictActiveOrder', () => ({ - usePredictActiveOrder: () => ({ - activeOrder: mockActiveOrder, - }), -})); - -function runHook(params: { - onConfirm?: () => void; - onFail?: (errorMessage?: string) => void; - transactions?: TransactionMeta[]; -}) { - return renderHookWithProvider( - () => - usePredictPayWithAnyTokenTracking({ - onConfirm: params.onConfirm, - onFail: params.onFail, - }), - { - state: { - engine: { - backgroundState: { - TransactionController: { - transactions: params.transactions ?? [], - }, - }, - }, - }, - }, - ); -} - -describe('usePredictPayWithAnyTokenTracking', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockActiveOrder = null; - mockRouteParams = { isConfirmation: false }; - }); - - describe('status detection', () => { - it('returns isConfirmed true when transaction with matching batchId is confirmed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isConfirmed).toBe(true); - expect(result.current.isFailed).toBe(false); - expect(result.current.isProcessing).toBe(false); - }); - - it('returns isFailed true when transaction with matching batchId failed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isFailed).toBe(true); - expect(result.current.isConfirmed).toBe(false); - }); - - it('returns isFailed true when transaction is rejected', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.rejected, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isFailed).toBe(true); - expect(result.current.isConfirmed).toBe(false); - }); - - it('returns neutral state when batchId is undefined', () => { - mockActiveOrder = {}; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isConfirmed).toBe(false); - expect(result.current.isFailed).toBe(false); - expect(result.current.isProcessing).toBe(false); - expect(result.current.errorMessage).toBeUndefined(); - }); - - it('returns isProcessing true when transaction is signed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.signed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isProcessing).toBe(true); - expect(result.current.isConfirmed).toBe(false); - expect(result.current.isFailed).toBe(false); - }); - - it('returns isProcessing true when transaction is submitted', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.submitted, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isProcessing).toBe(true); - expect(result.current.isConfirmed).toBe(false); - expect(result.current.isFailed).toBe(false); - }); - - it('returns error message from transaction.error.message', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - error: { message: 'Transaction reverted' }, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.errorMessage).toBe('Transaction reverted'); - }); - - it('returns error message from transaction.errormsg as fallback', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - errormsg: 'Fallback error message', - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.errorMessage).toBe('Fallback error message'); - }); - }); - - describe('controller error handling', () => { - it('returns isFailed true when activeOrder has error and batchId is PREDICTION_ERROR_TRANSACTION_BATCH_ID', () => { - mockActiveOrder = { - batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID, - error: 'Controller error occurred', - }; - - const { result } = runHook({ - transactions: [], - }); - - expect(result.current.isFailed).toBe(true); - }); - }); - - describe('onConfirm callback', () => { - it('calls onConfirm when transaction becomes confirmed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onConfirm = jest.fn(); - - runHook({ - onConfirm, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - - it('calls onConfirm only once across rerenders', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onConfirm = jest.fn(); - - const { rerender } = runHook({ - onConfirm, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - rerender(undefined); - - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - }); - - describe('onFail callback', () => { - it('calls onFail with error message when transaction fails', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onFail = jest.fn(); - - runHook({ - onFail, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - error: { message: 'Transaction failed on-chain' }, - } as unknown as TransactionMeta, - ], - }); - - expect(onFail).toHaveBeenCalledTimes(1); - expect(onFail).toHaveBeenCalledWith('Transaction failed on-chain'); - }); - - it('calls onFail only once across rerenders', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onFail = jest.fn(); - - const { rerender } = runHook({ - onFail, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - error: { message: 'fail' }, - } as unknown as TransactionMeta, - ], - }); - - rerender(undefined); - - expect(onFail).toHaveBeenCalledTimes(1); - }); - - it('calls onFail with activeOrder.error when controller error occurs', () => { - mockActiveOrder = { - batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID, - error: 'Controller error occurred', - }; - const onFail = jest.fn(); - - runHook({ - onFail, - transactions: [], - }); - - expect(onFail).toHaveBeenCalledTimes(1); - expect(onFail).toHaveBeenCalledWith('Controller error occurred'); - }); - }); - - describe('batchId change tracking', () => { - it('resets callback dedup refs when batchId changes', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onConfirm = jest.fn(); - - const { rerender } = runHook({ - onConfirm, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - { - id: 'tx-2', - batchId: 'batch-2', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(onConfirm).toHaveBeenCalledTimes(1); - - mockActiveOrder = { batchId: 'batch-2' }; - rerender(undefined); - - expect(onConfirm).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts deleted file mode 100644 index 258f923959b..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { TransactionStatus } from '@metamask/transaction-controller'; -import { useEffect, useMemo, useRef } from 'react'; -import { useSelector } from 'react-redux'; -import { RootState } from '../../../../../../reducers'; -import { selectTransactionsByBatchId } from '../../../../../../selectors/transactionController'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -import { RouteProp, useRoute } from '@react-navigation/native'; -import { PredictNavigationParamList } from '../../../types/navigation'; -import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../../../constants/transactions'; - -interface UsePredictPayWithAnyTokenTrackingParams { - onConfirm?: () => void; - onFail?: (errorMessage?: string) => void; -} - -function getTransactionErrorMessage( - transactionMeta: { - error?: unknown; - errormsg?: unknown; - } | null, -) { - const errorMessage = - typeof transactionMeta?.error === 'object' && - transactionMeta?.error && - 'message' in transactionMeta.error && - typeof transactionMeta.error.message === 'string' - ? transactionMeta.error.message - : undefined; - - if (errorMessage && errorMessage.trim() !== '') { - return errorMessage; - } - - if ( - typeof transactionMeta?.errormsg === 'string' && - transactionMeta.errormsg.trim() !== '' - ) { - return transactionMeta.errormsg; - } - - return undefined; -} - -export function usePredictPayWithAnyTokenTracking({ - onConfirm, - onFail, -}: UsePredictPayWithAnyTokenTrackingParams) { - const hasCalledConfirmRef = useRef(false); - const hasCalledFailRef = useRef(false); - - const route = - useRoute>(); - - const { isConfirmation } = route.params; - - const { activeOrder } = usePredictActiveOrder(); - - const batchId = useMemo(() => activeOrder?.batchId, [activeOrder?.batchId]); - const error = useMemo(() => activeOrder?.error, [activeOrder?.error]); - - const trackedBatchIdRef = useRef(batchId); - - const transactions = useSelector((state: RootState) => - batchId ? selectTransactionsByBatchId(state, batchId) : null, - ); - - const transactionMeta = useMemo(() => transactions?.[0], [transactions]); - - const status = transactionMeta?.status; - const errorMessage = getTransactionErrorMessage(transactionMeta ?? null); - const isConfirmed = status === TransactionStatus.confirmed; - const isControllerError = - !!error && batchId === PREDICTION_ERROR_TRANSACTION_BATCH_ID; - const isFailed = - isControllerError || - status === TransactionStatus.failed || - status === TransactionStatus.rejected; - - const isProcessing = - status === TransactionStatus.signed || - status === TransactionStatus.submitted; - - useEffect(() => { - if (trackedBatchIdRef.current === batchId) { - return; - } - - trackedBatchIdRef.current = batchId; - hasCalledConfirmRef.current = false; - hasCalledFailRef.current = false; - }, [batchId]); - - useEffect(() => { - if (!batchId || !isConfirmed || !onConfirm || hasCalledConfirmRef.current) { - return; - } - - hasCalledConfirmRef.current = true; - onConfirm(); - }, [batchId, isConfirmed, onConfirm]); - - useEffect(() => { - if (isFailed && !hasCalledFailRef.current && onFail) { - hasCalledFailRef.current = true; - onFail(error ?? errorMessage); - } - }, [batchId, isFailed, onFail, error, errorMessage, isConfirmation]); - - return { - isConfirmed, - isFailed, - errorMessage, - isProcessing, - status, - }; -} diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 568630de21a..15658ddc66b 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -60,7 +60,6 @@ jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx index 37c7426971a..d6f9d54ec9d 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx @@ -5,7 +5,7 @@ import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import { IconName } from '../../../../../../../component-library/components/Icons/Icon'; +import { IconName } from '@metamask/design-system-react-native'; import Routes from '../../../../../../../constants/navigation/Routes'; import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils'; import MenuItem from '../../../../components/MenuItem'; diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap index e74adcf69d6..56d468e0e5e 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap @@ -565,17 +565,18 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` testID="listitemcolumn" > View order history @@ -652,17 +657,18 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` testID="listitemcolumn" > More ways to buy @@ -699,13 +709,17 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` Switch to the new version diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx index e29aec970d3..55e2c1b7a68 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Linking } from 'react-native'; +import { IconColor } from '../../../../../../../component-library/components/Icons/Icon'; import ConfigurationModal from './ConfigurationModal'; import { renderScreen } from '../../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../../util/test/initial-root-state'; @@ -194,7 +195,7 @@ describe('ConfigurationModal', () => { variant: 'Icon', labelOptions: [{ label: 'Successfully logged out' }], iconName: 'CheckBold', - iconColor: 'Success', + iconColor: IconColor.Success, hasNoTimeout: false, }); }); @@ -215,7 +216,7 @@ describe('ConfigurationModal', () => { variant: 'Icon', labelOptions: [{ label: 'Error logging out' }], iconName: 'CircleX', - iconColor: 'Error', + iconColor: IconColor.Error, hasNoTimeout: false, }); }); diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx index 5e04149a32c..06de34cb0fb 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx @@ -3,9 +3,10 @@ import { Linking } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; +import { IconName } from '@metamask/design-system-react-native'; import { - IconName, - IconColor, + IconName as ComponentLibraryIconName, + IconColor as ComponentLibraryIconColor, } from '../../../../../../../component-library/components/Icons/Icon'; import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils'; @@ -87,8 +88,9 @@ function ConfigurationModal() { labelOptions: [ { label: strings('deposit.configuration_modal.logged_out_success') }, ], - iconName: IconName.CheckBold, - iconColor: IconColor.Success, + iconName: ComponentLibraryIconName.CheckBold, + // Toast still renders component-library Icon; use its IconColor enum, not DS tokens. + iconColor: ComponentLibraryIconColor.Success, hasNoTimeout: false, }); } catch (error) { @@ -98,8 +100,8 @@ function ConfigurationModal() { labelOptions: [ { label: strings('deposit.configuration_modal.logged_out_error') }, ], - iconName: IconName.CircleX, - iconColor: IconColor.Error, + iconName: ComponentLibraryIconName.CircleX, + iconColor: ComponentLibraryIconColor.Error, hasNoTimeout: false, }); } diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap index 192f369902b..0616ccc2771 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap @@ -565,17 +565,18 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` testID="listitemcolumn" > View order history @@ -652,17 +657,18 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` testID="listitemcolumn" > Contact support @@ -739,17 +749,18 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` testID="listitemcolumn" > More ways to buy @@ -786,13 +801,17 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` Switch to the classic version diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx index 804fa2adc88..31229485397 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx @@ -31,7 +31,7 @@ import { useDepositCryptoCurrencyNetworkName } from '../../../hooks/useDepositCr import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk'; import Routes from '../../../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../../../locales/i18n'; -import { useTheme } from '../../../../../../../util/theme'; +import { TextColor } from '@metamask/design-system-react-native'; import useAnalytics from '../../../../hooks/useAnalytics'; import { getRampRoutingDecision } from '../../../../../../../reducers/fiatOrders'; @@ -58,7 +58,6 @@ function TokenSelectorModal() { screenHeight, }); - const { colors } = useTheme(); const trackEvent = useAnalytics(); const getNetworkName = useDepositCryptoCurrencyNetworkName(); const rampRoutingDecision = useSelector(getRampRoutingDecision); @@ -140,14 +139,10 @@ function TokenSelectorModal() { token={token} isSelected={selectedCryptoCurrency?.assetId === token.assetId} onPress={() => handleSelectAssetIdCallback(token.assetId)} - textColor={colors.text.alternative} + textColor={TextColor.TextAlternative} /> ), - [ - colors.text.alternative, - handleSelectAssetIdCallback, - selectedCryptoCurrency?.assetId, - ], + [handleSelectAssetIdCallback, selectedCryptoCurrency?.assetId], ); const renderEmptyList = useCallback( diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap index 08d15c5992c..1f0b32370c8 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap @@ -3347,13 +3347,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USD Coin @@ -3361,13 +3365,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USDC @@ -3578,13 +3586,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] Tether USD @@ -3592,13 +3604,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USDT @@ -3794,13 +3810,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] Bitcoin @@ -3808,13 +3828,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] BTC @@ -4010,13 +4034,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] Ethereum @@ -4024,13 +4052,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] ETH @@ -4226,13 +4258,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USD Coin @@ -4240,13 +4276,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USDC diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts index 361d8438c30..5409b498cb1 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts @@ -13,12 +13,6 @@ const styleSheet = (params: { theme: Theme }) => { gap: 16, flex: 1, }, - mainAmount: { - textAlign: 'center', - fontSize: 64, - lineHeight: 64 + 8, - fontWeight: '400', - }, amountContainer: { alignItems: 'center', gap: 16, @@ -29,9 +23,8 @@ const styleSheet = (params: { theme: Theme }) => { }, cursor: { width: 2, - height: 48, marginHorizontal: 1, - marginBottom: 12, + alignSelf: 'center', backgroundColor: theme.colors.primary.default, }, actionSection: { diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index b1460c3f7b1..85b3733c69f 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -26,11 +26,11 @@ import { reportRampsError } from '../../utils/reportRampsError'; import Keypad, { type KeypadChangeData, Keys } from '../../../../Base/Keypad'; import PaymentMethodPill from '../../components/PaymentMethodPill'; import QuickAmounts from '../../components/QuickAmounts'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { + FontWeight, Button, ButtonVariant, ButtonSize, @@ -42,6 +42,7 @@ import HeaderCompactStandard from '../../../../../component-library/components-t import Routes from '../../../../../constants/navigation/Routes'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './BuildQuote.styles'; +import { getFontSizeForInputLength } from './getFontSizeForInputLength'; import { useFormatters } from '../../../../hooks/useFormatters'; import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo'; import { @@ -312,6 +313,13 @@ function BuildQuote() { }, [currency, formatCurrency]); const quickAmounts = userRegion?.country?.quickAmounts ?? [50, 100, 200, 400]; + const amountDisplayString = useMemo( + () => `${currencyPrefix}${amount}${currencySuffix}`, + [currencyPrefix, currencySuffix, amount], + ); + const amountFontSize = getFontSizeForInputLength(amountDisplayString.length); + const amountLineHeight = amountFontSize + 10; + /* * Tracks RAMPS_SCREEN_VIEWED * @returns {void} @@ -829,31 +837,38 @@ function BuildQuote() { {currencyPrefix} {amount} {currencySuffix ? ( {currencySuffix} @@ -906,7 +921,7 @@ function BuildQuote() { ) : ( selectedProvider && ( {strings('fiat_on_ramp.powered_by_provider', { diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index b6df390f00e..2ea1337f785 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -298,16 +298,20 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n @@ -318,6 +322,7 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -326,74 +331,56 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n - + Debit/Credit Card - + @@ -416,19 +403,24 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > An unexpected error occurred. @@ -447,17 +439,18 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n onPress={[Function]} > @@ -1004,16 +997,20 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe @@ -1024,6 +1021,7 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -1032,74 +1030,56 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe - + Debit/Credit Card - + @@ -1122,19 +1102,24 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > Network error @@ -1153,17 +1138,18 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe onPress={[Function]} > @@ -1710,16 +1696,20 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou @@ -1730,6 +1720,7 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -1738,74 +1729,56 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou - + Debit/Credit Card - + @@ -1828,19 +1801,24 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > Routing failed @@ -1859,17 +1837,18 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou onPress={[Function]} > @@ -2416,16 +2395,20 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg @@ -2436,6 +2419,7 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -2444,74 +2428,56 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg - + Debit/Credit Card - + @@ -2534,19 +2500,24 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > No widget URL available for provider @@ -2565,17 +2536,18 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg onPress={[Function]} > @@ -3122,16 +3094,20 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg @@ -3142,6 +3118,7 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -3150,74 +3127,56 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg - + Debit/Credit Card - + @@ -3240,19 +3199,24 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > Network request failed @@ -3271,17 +3235,18 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg onPress={[Function]} > @@ -3828,16 +3793,20 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle @@ -3848,6 +3817,7 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -3856,74 +3826,56 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle - + Debit/Credit Card - + @@ -3991,13 +3943,17 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle Powered by MoonPay diff --git a/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts new file mode 100644 index 00000000000..d1e9a476cc9 --- /dev/null +++ b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts @@ -0,0 +1,22 @@ +import { getFontSizeForInputLength } from './getFontSizeForInputLength'; + +describe('getFontSizeForInputLength', () => { + it.each([ + [0, 60], + [7, 60], + [8, 60], + [9, 48], + [10, 48], + [11, 32], + [12, 32], + [13, 24], + [14, 24], + [15, 18], + [17, 18], + [18, 18], + [19, 12], + [30, 12], + ])('returns correct size for content length %i', (length, expected) => { + expect(getFontSizeForInputLength(length)).toBe(expected); + }); +}); diff --git a/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts new file mode 100644 index 00000000000..8f6e786ee4e --- /dev/null +++ b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts @@ -0,0 +1,22 @@ +/** + * Matches Predict amount display scaling — see PredictAmountDisplay.tsx + * (getFontSizeForInputLength + lineHeight = fontSize + 10). + */ +export function getFontSizeForInputLength(contentLength: number): number { + if (contentLength <= 8) { + return 60; + } + if (contentLength <= 10) { + return 48; + } + if (contentLength <= 12) { + return 32; + } + if (contentLength <= 14) { + return 24; + } + if (contentLength <= 18) { + return 18; + } + return 12; +} diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx index 38e3a4b62b0..021be32772f 100644 --- a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx @@ -10,17 +10,16 @@ import { Button, ButtonVariant, ButtonBaseSize, + Icon, + IconName, + IconSize, + IconColor, } from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; -import Icon, { - IconName, - IconSize, - IconColor, -} from '../../../../../../component-library/components/Icons/Icon'; -import { useStyles } from '../../../../../../component-library/hooks'; +import { useStyles } from '../../../../../hooks/useStyles'; import { createNavigationDetails, useParams, @@ -102,7 +101,7 @@ function ErrorDetailsModal() { {strings('deposit.errors.error_details_title')} diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap index acdb5568719..b24160cd985 100644 --- a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap @@ -358,17 +358,18 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` } > { - const { View } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = + jest.requireActual('react-native'); return { - Skeleton: ({ width, height }: { width: number; height: number }) => ( - - ), + Skeleton: ({ width, height }: { width: number; height: number }) => + ReactActual.createElement(View, { + testID: 'skeleton', + style: { width, height }, + }), }; }); -jest.mock('../../../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View } = + jest.requireActual('react-native'); + const actual = jest.requireActual< + typeof import('@metamask/design-system-react-native') + >('@metamask/design-system-react-native'); return { - __esModule: true, - IconName: { Warning: 'Warning' }, - IconSize: { Sm: '16' }, - IconColor: { Warning: 'Warning' }, - default: ({ testID }: { testID?: string }) => ( - - ), + ...actual, + Icon: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID: testID ?? 'icon' }), }; }); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx index 4eb25dea5a8..d04fe87a740 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx @@ -6,12 +6,11 @@ import { TextVariant, TextColor, FontWeight, -} from '@metamask/design-system-react-native'; -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../../component-library/components/Icons/Icon'; +} from '@metamask/design-system-react-native'; import { Skeleton } from '../../../../../../component-library/components/Skeleton'; import { strings } from '../../../../../../../locales/i18n'; @@ -68,7 +67,7 @@ const QuoteDisplay: React.FC = ({ ); diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx index e80ea5a968b..807e30fa915 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { IconColor } from '../../../../../../component-library/components/Icons/Icon'; import SettingsModal from './SettingsModal'; import InAppBrowser from 'react-native-inappbrowser-reborn'; import { renderScreen } from '../../../../../../util/test/renderWithProvider'; @@ -291,7 +292,7 @@ describe('SettingsModal', () => { variant: 'Icon', labelOptions: [{ label: 'Successfully logged out' }], iconName: 'CheckBold', - iconColor: 'Success', + iconColor: IconColor.Success, hasNoTimeout: false, }); }); @@ -315,7 +316,7 @@ describe('SettingsModal', () => { variant: 'Icon', labelOptions: [{ label: 'Error logging out' }], iconName: 'CircleX', - iconColor: 'Error', + iconColor: IconColor.Error, hasNoTimeout: false, }); }); diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx index 31d2560c975..297117e06ed 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx @@ -9,9 +9,10 @@ import InAppBrowser from 'react-native-inappbrowser-reborn'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import { IconName } from '@metamask/design-system-react-native'; import { - IconName, - IconColor, + IconName as ComponentLibraryIconName, + IconColor as ComponentLibraryIconColor, } from '../../../../../../component-library/components/Icons/Icon'; import { createNavigationDetails } from '../../../../../../util/navigation/navUtils'; import Routes from '../../../../../../constants/navigation/Routes'; @@ -166,8 +167,9 @@ function SettingsModal() { ), }, ], - iconName: IconName.CheckBold, - iconColor: IconColor.Success, + iconName: ComponentLibraryIconName.CheckBold, + // Toast still renders component-library Icon; use its IconColor enum, not DS tokens. + iconColor: ComponentLibraryIconColor.Success, hasNoTimeout: false, }); } catch (error) { @@ -181,8 +183,8 @@ function SettingsModal() { ), }, ], - iconName: IconName.CircleX, - iconColor: IconColor.Error, + iconName: ComponentLibraryIconName.CircleX, + iconColor: ComponentLibraryIconColor.Error, hasNoTimeout: false, }); } diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap index 876cadc62ae..b3de2e506ca 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap @@ -610,17 +610,18 @@ exports[`SettingsModal render matches snapshot 1`] = ` testID="listitemcolumn" > View order history @@ -697,17 +702,18 @@ exports[`SettingsModal render matches snapshot 1`] = ` testID="listitemcolumn" > Contact support diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 00a4377856f..20eb987884b 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -1,20 +1,20 @@ import React, { useCallback } from 'react'; import { Image } from 'react-native'; -import Text from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; +import { + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/AdditionalVerification/AdditionalVerification.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { useNavigation } from '@react-navigation/native'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import additionalVerificationImage from '../../Deposit/assets/additional-verification.png'; import { strings } from '../../../../../../locales/i18n'; -import { TextVariant } from '../../../../../component-library/components/Texts/Text/Text.types'; import { useTransakRouting } from '../../hooks/useTransakRouting'; import { useParams } from '../../../../../util/navigation/navUtils'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; @@ -61,14 +61,14 @@ const V2AdditionalVerification = () => { resizeMode={'contain'} style={styles.image} /> - + {strings('deposit.additional_verification.title')} - + {strings('deposit.additional_verification.paragraph_1')} - + {strings('deposit.additional_verification.paragraph_2')} @@ -78,10 +78,11 @@ const V2AdditionalVerification = () => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx index f28632c910f..c6cd5adb5ec 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx @@ -5,18 +5,22 @@ import styleSheet from '../../Deposit/Views/BankDetails/BankDetails.styles'; import { useNavigation } from '@react-navigation/native'; import { useParams } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import Icon, { + Icon, IconName, IconSize, -} from '../../../../../component-library/components/Icons/Icon'; + IconColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import Loader from '../../../../../component-library/components-temp/Loader/Loader'; import BankDetailRow from '../../Deposit/components/BankDetailRow'; import { @@ -25,10 +29,6 @@ import { normalizeProviderCode, } from '@metamask/ramps-controller'; import { useTheme } from '../../../../../util/theme'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; import PrivacySection from '../../Deposit/components/PrivacySection'; import useAnalytics from '../../hooks/useAnalytics'; @@ -327,13 +327,19 @@ const V2BankDetails = () => { - + {strings('deposit.bank_details.main_title')} - + {strings('deposit.bank_details.main_content_1')} - + {strings('deposit.bank_details.main_content_2')} @@ -420,7 +426,10 @@ const V2BankDetails = () => { style={styles.showBankInfoButton} onPress={toggleBankInfo} > - + {showBankInfo ? strings('deposit.bank_details.hide_bank_info') : strings('deposit.bank_details.show_bank_info')} @@ -428,7 +437,7 @@ const V2BankDetails = () => { @@ -443,18 +452,21 @@ const V2BankDetails = () => { {confirmPaymentError ? ( - + {confirmPaymentError} ) : null} {cancelOrderError ? ( - + {strings('deposit.bank_details.cancel_order_error')} ) : null} - + {strings('deposit.bank_details.info_banner_text', { accountHolderName: accountName, })} @@ -464,24 +476,26 @@ const V2BankDetails = () => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx index 48156f32b8b..1bc9436310c 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx @@ -2,9 +2,17 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Keyboard, TextInput, TouchableOpacity, View } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; + Icon, + IconName, + IconSize, + IconColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { useStyles } from '../../../../hooks/useStyles'; @@ -18,16 +26,6 @@ import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import DepositDateField from '../../Deposit/components/DepositDateField'; import { VALIDATION_REGEX } from '../../Deposit/constants/constants'; import { formatNumberToTemplate } from '../../Deposit/components/DepositPhoneField/formatNumberToTemplate'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; import PrivacySection from '../../Deposit/components/PrivacySection'; import { timestampToTransakFormat } from '../../Deposit/utils'; @@ -35,6 +33,8 @@ import useAnalytics from '../../hooks/useAnalytics'; import Logger from '../../../../../util/Logger'; import BannerAlert from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert'; import { BannerAlertSeverity } from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import { ButtonVariants } from '../../../../../component-library/components/Buttons/Button'; +import { TextVariant as ComponentLibraryTextVariant } from '../../../../../component-library/components/Texts/Text/Text.types'; import { useTransakController } from '../../hooks/useTransakController'; import { useRampsUserRegion } from '../../hooks/useRampsUserRegion'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; @@ -322,7 +322,7 @@ const V2BasicInfo = (): JSX.Element => { > - + {strings('deposit.basic_info.title')} @@ -339,7 +339,7 @@ const V2BasicInfo = (): JSX.Element => { variant: ButtonVariants.Link, label: strings('deposit.basic_info.login_with_email'), onPress: handleLogout, - labelTextVariant: TextVariant.BodyMD, + labelTextVariant: ComponentLibraryTextVariant.BodyMD, testID: BASIC_INFO_TEST_IDS.LOGOUT_BUTTON, } : undefined @@ -449,7 +449,7 @@ const V2BasicInfo = (): JSX.Element => { - + {strings('deposit.basic_info.social_security_number')} { @@ -490,13 +490,14 @@ const V2BasicInfo = (): JSX.Element => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx index 170a75b94b6..6596cdaf163 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx @@ -2,9 +2,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { View, TextInput, Keyboard } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { useStyles } from '../../../../hooks/useStyles'; @@ -15,11 +19,6 @@ import DepositTextField from '../../Deposit/components/DepositTextField'; import { useForm } from '../../Deposit/hooks/useForm'; import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import PrivacySection from '../../Deposit/components/PrivacySection'; import { VALIDATION_REGEX } from '../../Deposit/constants/constants'; import Logger from '../../../../../util/Logger'; @@ -228,7 +227,7 @@ const V2EnterAddress = (): JSX.Element => { - + {strings('deposit.enter_address.title')} @@ -356,13 +355,14 @@ const V2EnterAddress = (): JSX.Element => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx index 50fe451ed5f..8b365ba66b5 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx @@ -1,9 +1,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { TextInput, View } from 'react-native'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/EnterEmail/EnterEmail.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { @@ -18,11 +22,6 @@ import { getDepositNavbarOptions } from '../../../Navbar'; import { createV2OtpCodeNavDetails } from './OtpCode'; import { validateEmail } from '../../Deposit/utils'; import DepositProgressBar from '../../Deposit/components/DepositProgressBar/DepositProgressBar'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; import Logger from '../../../../../util/Logger'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -142,10 +141,10 @@ const V2EnterEmail = () => { - + {strings('deposit.enter_email.title')} - + {strings('deposit.enter_email.description')} @@ -165,12 +164,16 @@ const V2EnterEmail = () => { /> {validationError && ( - + {strings('deposit.enter_email.validation_error')} )} - {error && {error}} + {error && ( + + {error} + + )} @@ -181,12 +184,13 @@ const V2EnterEmail = () => { testID={EnterEmailSelectorsIDs.SEND_EMAIL_BUTTON} size={ButtonSize.Lg} onPress={handleSubmit} - label={strings('deposit.enter_email.submit_button')} - variant={ButtonVariants.Primary} - width={ButtonWidthTypes.Full} - loading={isLoading} + variant={ButtonVariant.Primary} + isFullWidth + isLoading={isLoading} isDisabled={isLoading} - /> + > + {strings('deposit.enter_email.submit_button')} + diff --git a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx index 5b39160a9ea..029655dd1bd 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx @@ -4,23 +4,22 @@ import styleSheet from '../../Deposit/Views/KycProcessing/KycProcessing.styles'; import { useNavigation } from '@react-navigation/native'; import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import { useParams } from '../../../../../util/navigation/navUtils'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../component-library/components/Icons/Icon'; -import Button, { + Button, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; + ButtonVariant, + FontWeight, +} from '@metamask/design-system-react-native'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; import { KycStatus } from '../../Deposit/constants'; import Logger from '../../../../../util/Logger'; @@ -174,12 +173,12 @@ const V2KycProcessing = () => { - + {strings('deposit.kyc_processing.error_heading')} - + {error || strings('deposit.kyc_processing.error_description')} @@ -190,10 +189,11 @@ const V2KycProcessing = () => { @@ -212,13 +212,17 @@ const V2KycProcessing = () => { - + {strings('deposit.kyc_processing.success_heading')} - + {strings('deposit.kyc_processing.success_description')} @@ -229,10 +233,11 @@ const V2KycProcessing = () => { @@ -251,10 +256,14 @@ const V2KycProcessing = () => { color={theme.colors.primary.default} testID={KYC_PROCESSING_TEST_IDS.ACTIVITY_INDICATOR} /> - + {strings('deposit.kyc_processing.heading')} - + {strings('deposit.kyc_processing.description')} diff --git a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx index b6f6288b7ab..a4bdfe21deb 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx @@ -5,7 +5,7 @@ import styleSheet from '../../Deposit/Views/OrderProcessing/OrderProcessing.styl import { useNavigation } from '@react-navigation/native'; import { useParams } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { getOrderById } from '../../../../../reducers/fiatOrders'; @@ -14,10 +14,11 @@ import { strings } from '../../../../../../locales/i18n'; import DepositOrderContent from '../../Deposit/components/DepositOrderContent/DepositOrderContent'; import { FIAT_ORDER_STATES } from '../../../../../constants/on-ramp'; import { TRANSAK_SUPPORT_URL } from '../../Deposit/constants'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import Loader from '../../../../../component-library/components-temp/Loader/Loader'; import { ORDER_PROCESSING_TEST_IDS } from './OrderProcessing.testIds'; @@ -89,27 +90,25 @@ const V2OrderProcessing = () => { order.state === FIAT_ORDER_STATES.FAILED) && ( )} diff --git a/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx b/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx index 034cbc63bde..8acede1842a 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx @@ -1,11 +1,17 @@ import React, { useCallback, useState, useEffect, useRef, FC } from 'react'; import { TextInput, View, TouchableOpacity, Linking } from 'react-native'; import Clipboard from '@react-native-clipboard/clipboard'; -import Text, { +import { + Box, + BoxAlignItems, + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/OtpCode/OtpCode.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { @@ -26,16 +32,10 @@ import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import Row from '../../Aggregator/components/Row'; import { TRANSAK_SUPPORT_URL } from '../../Deposit/constants'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Logger from '../../../../../util/Logger'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { trace, TraceName } from '../../../../../util/trace'; -import { Box, BoxAlignItems } from '@metamask/design-system-react-native'; import { useTransakController } from '../../hooks/useTransakController'; import { useTransakRouting } from '../../hooks/useTransakRouting'; import { useRampsController } from '../../hooks/useRampsController'; @@ -336,7 +336,7 @@ const V2OtpCode = () => { - + {strings('deposit.otp_code.title')} @@ -345,8 +345,8 @@ const V2OtpCode = () => { @@ -380,7 +380,9 @@ const V2OtpCode = () => { /> {error && ( - {error} + + {error} + )} @@ -414,13 +416,14 @@ const V2OtpCode = () => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx b/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx index e757bcc6695..e6f0a4fafa5 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx @@ -1,10 +1,14 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Image, Linking, ScrollView } from 'react-native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/VerifyIdentity/VerifyIdentity.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import Routes from '../../../../../constants/navigation/Routes'; @@ -13,11 +17,6 @@ import { getDepositNavbarOptions } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; import VerifyIdentityImage from '../../Deposit/assets/verifyIdentityIllustration.png'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { TRANSAK_TERMS_URL_US, TRANSAK_TERMS_URL_WORLD, @@ -176,24 +175,32 @@ const V2VerifyIdentity = () => { resizeMode={'contain'} style={styles.image} /> - + {strings('deposit.verify_identity.title')} - + {strings('deposit.verify_identity.description_1')} - - + + {strings('deposit.verify_identity.description_2_transak')} {strings('deposit.verify_identity.description_2_rest')} - + {strings('deposit.verify_identity.description_3_part1')} { {strings('deposit.verify_identity.agreement_text_part1')} @@ -225,8 +232,8 @@ const V2VerifyIdentity = () => { {strings('deposit.verify_identity.agreement_text_and')} { testID={VerifyIdentitySelectorsIDs.CONTINUE_BUTTON} size={ButtonSize.Lg} onPress={handleSubmit} - label={strings('deposit.verify_identity.button')} - variant={ButtonVariants.Primary} - width={ButtonWidthTypes.Full} - /> + variant={ButtonVariant.Primary} + isFullWidth + > + {strings('deposit.verify_identity.button')} + diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap index 1dbe3478ade..e8e5cfb67bd 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap @@ -65,15 +65,20 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` deposit.additional_verification.title @@ -81,14 +86,19 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` deposit.additional_verification.paragraph_1 @@ -96,14 +106,19 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` deposit.additional_verification.paragraph_2 @@ -125,42 +140,90 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` ] } > - deposit.additional_verification.button - + deposit.bank_details.main_title @@ -99,13 +103,17 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` deposit.bank_details.main_content_1 @@ -113,13 +121,17 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` deposit.bank_details.main_content_2 @@ -215,13 +227,17 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` deposit.bank_details.info_banner_text @@ -237,87 +253,183 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + @@ -411,13 +523,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.main_title @@ -425,13 +541,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.main_content_1 @@ -439,13 +559,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.main_content_2 @@ -525,29 +649,34 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.show_bank_info @@ -638,13 +767,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.info_banner_text @@ -660,77 +793,244 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel } } > - - - - + + + + + + + + + deposit.order_processing.cancel_order_button + + + deposit.bank_details.button - + @@ -824,13 +1124,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.main_title @@ -838,13 +1142,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.main_content_1 @@ -852,13 +1160,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.main_content_2 @@ -938,29 +1250,34 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.show_bank_info @@ -1051,13 +1368,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.info_banner_text @@ -1073,77 +1394,244 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm } } > - deposit.order_processing.cancel_order_button - - + - - + + + + + + + + + deposit.bank_details.button + + @@ -1237,13 +1725,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.main_title @@ -1251,13 +1743,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.main_content_1 @@ -1265,13 +1761,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.main_content_2 @@ -1351,29 +1851,34 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.show_bank_info @@ -1464,13 +1969,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.info_banner_text @@ -1486,87 +1995,183 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + @@ -1660,13 +2265,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.main_title @@ -1674,13 +2283,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.main_content_1 @@ -1688,13 +2301,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.main_content_2 @@ -1754,29 +2371,34 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.show_bank_info @@ -1867,13 +2489,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.info_banner_text @@ -1889,87 +2515,183 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + @@ -2063,13 +2785,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.main_title @@ -2077,13 +2803,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.main_content_1 @@ -2091,13 +2821,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.main_content_2 @@ -2177,29 +2911,34 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.show_bank_info @@ -2290,13 +3029,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.info_banner_text @@ -2312,87 +3055,183 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap index fc9f9e088d7..d3792f42aa2 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap @@ -174,15 +174,20 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` deposit.basic_info.title @@ -190,14 +195,20 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` deposit.basic_info.subtitle @@ -552,13 +563,19 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` 🇬🇧 @@ -853,45 +870,91 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` - deposit.basic_info.continue - + deposit.basic_info.title @@ -1098,14 +1166,20 @@ exports[`V2BasicInfo matches snapshot 1`] = ` deposit.basic_info.subtitle @@ -1460,13 +1534,19 @@ exports[`V2BasicInfo matches snapshot 1`] = ` 🇺🇸 @@ -1474,13 +1554,20 @@ exports[`V2BasicInfo matches snapshot 1`] = ` +1 @@ -1695,13 +1782,17 @@ exports[`V2BasicInfo matches snapshot 1`] = ` deposit.basic_info.social_security_number @@ -1711,17 +1802,18 @@ exports[`V2BasicInfo matches snapshot 1`] = ` testID="ssn-info-button" > @@ -1927,45 +2019,91 @@ exports[`V2BasicInfo matches snapshot 1`] = ` - deposit.basic_info.continue - + deposit.basic_info.title @@ -2172,14 +2315,20 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` deposit.basic_info.subtitle @@ -2534,13 +2683,19 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` 🇬🇧 @@ -2548,13 +2703,20 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` +44 @@ -2849,45 +3011,91 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` - deposit.basic_info.continue - + deposit.enter_address.title @@ -199,13 +203,19 @@ exports[`V2EnterAddress matches snapshot 1`] = ` deposit.enter_address.subtitle @@ -914,13 +924,19 @@ exports[`V2EnterAddress matches snapshot 1`] = ` 🇺🇸 @@ -1074,45 +1090,91 @@ exports[`V2EnterAddress matches snapshot 1`] = ` - deposit.enter_address.continue - + deposit.enter_address.title @@ -1329,13 +1395,19 @@ exports[`V2EnterAddress matches snapshot for non-US region 1`] = ` deposit.enter_address.subtitle @@ -2044,13 +2116,19 @@ exports[`V2EnterAddress matches snapshot for non-US region 1`] = ` 🇬🇧 @@ -2204,45 +2282,91 @@ exports[`V2EnterAddress matches snapshot for non-US region 1`] = ` - deposit.enter_address.continue - + deposit.enter_email.title @@ -80,13 +85,19 @@ exports[`V2EnterEmail matches snapshot 1`] = ` deposit.enter_email.description @@ -197,45 +208,91 @@ exports[`V2EnterEmail matches snapshot 1`] = ` ] } > - deposit.enter_email.submit_button - + deposit.kyc_processing.heading @@ -178,14 +183,19 @@ exports[`V2KycProcessing matches snapshot in loading state 1`] = ` deposit.kyc_processing.description diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap index f848535e762..6db6651cbf4 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap @@ -119,44 +119,94 @@ exports[`V2OrderProcessing matches snapshot when order is pending 1`] = ` } } > - deposit.order_processing.button - + diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap index c456a1391e4..62d929a98ab 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap @@ -146,15 +146,20 @@ exports[`V2OtpCode matches snapshot 1`] = ` deposit.otp_code.title @@ -162,14 +167,20 @@ exports[`V2OtpCode matches snapshot 1`] = ` deposit.otp_code.description @@ -189,13 +200,17 @@ exports[`V2OtpCode matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="otp-code-paste-button" > @@ -228,15 +243,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -260,15 +283,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -292,15 +323,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -324,15 +363,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -356,15 +403,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -388,15 +443,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -419,14 +482,20 @@ exports[`V2OtpCode matches snapshot 1`] = ` deposit.otp_code.resend_cooldown @@ -449,46 +518,91 @@ exports[`V2OtpCode matches snapshot 1`] = ` ] } > - deposit.otp_code.submit_button - + deposit.verify_identity.title @@ -90,14 +95,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.description_1 @@ -105,28 +115,38 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` - + deposit.verify_identity.description_2_transak @@ -136,14 +156,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.description_3_part1 @@ -151,14 +176,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#131416", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - "textDecorationLine": "underline", - } + [ + { + "color": "#131416", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + { + "textDecorationLine": "underline", + }, + ] } testID="privacy-policy-link-1" > @@ -188,14 +218,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.agreement_text_part1 @@ -203,14 +238,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#babbbe", - "fontFamily": "Geist-Regular", - "fontSize": 12, - "letterSpacing": 0.25, - "lineHeight": 20, - "textDecorationLine": "underline", - } + [ + { + "color": "#babbbe", + "fontFamily": "Geist-Regular", + "fontSize": 12, + "fontWeight": 400, + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "textDecorationLine": "underline", + }, + ] } > deposit.verify_identity.agreement_text_transak_terms @@ -220,14 +260,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#babbbe", - "fontFamily": "Geist-Regular", - "fontSize": 12, - "letterSpacing": 0.25, - "lineHeight": 20, - "textDecorationLine": "underline", - } + [ + { + "color": "#babbbe", + "fontFamily": "Geist-Regular", + "fontSize": 12, + "fontWeight": 400, + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "textDecorationLine": "underline", + }, + ] } testID="privacy-policy-link-2" > @@ -235,43 +280,91 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.agreement_text_part2 - deposit.verify_identity.button - + = ({ {showManageBankTransfer && ( )} @@ -644,12 +643,13 @@ const OrderContent: React.FC = ({ {showCloseButton && ( )} diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index e903408e333..3996ce64cd5 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -10,6 +10,9 @@ import { IconName, IconSize, FontWeight, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { normalizeProviderCode, @@ -21,11 +24,6 @@ import { getNavigateAfterExternalBrowserRoutes, type RampsOrderDetailsParams, } from '../../utils/rampsNavigation'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { strings } from '../../../../../../locales/i18n'; import { getRampsOrderDetailsNavbarOptions } from '../../../Navbar'; @@ -290,12 +288,13 @@ const OrderDetails = () => { {error} diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx index 292841cb191..2de02f95397 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx @@ -19,21 +19,22 @@ import { } from '@react-navigation/native'; import Fuse from 'fuse.js'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; import ListItemSelect from '../../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { WidthType, } from '../../../../../../component-library/components/List/ListItemColumn'; import TextFieldSearch from '../../../../../../component-library/components/Form/TextFieldSearch'; -import Icon, { +import { IconName as ComponentLibraryIconName } from '../../../../../../component-library/components/Icons/Icon'; +import { + Text, + TextColor, + TextVariant, + FontWeight, + Icon, IconName, -} from '../../../../../../component-library/components/Icons/Icon'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../../../component-library/components/Buttons/ButtonIcon'; + ButtonIcon, + ButtonIconSize, +} from '@metamask/design-system-react-native'; import styleSheet, { styles as navigationOptionsStyles, @@ -94,7 +95,7 @@ interface HeaderBackButtonProps { function HeaderBackButton({ onPress, testID }: HeaderBackButtonProps) { return ( {item.country.flag} @@ -381,17 +383,20 @@ function RegionSelector() { )} {item.country.name} {showStateName && userRegion.state && ( {userRegion.state.name} @@ -424,11 +429,12 @@ function RegionSelector() { {state.name || ''} @@ -471,9 +477,12 @@ function RegionSelector() { {region.flag && ( {region.flag} @@ -482,15 +491,21 @@ function RegionSelector() { )} {region.name} {showStateName && userRegion.state && ( - + {userRegion.state.name} )} @@ -519,9 +534,12 @@ function RegionSelector() { {region.name || ''} @@ -558,10 +576,14 @@ function RegionSelector() { if (countriesError && countries.length === 0) { return ( - + {strings('fiat_on_ramp_aggregator.error')} - + {countriesError} @@ -571,7 +593,7 @@ function RegionSelector() { if (searchString.length > 0) { return ( - + {strings('fiat_on_ramp_aggregator.region.no_region_results', { searchString, })} @@ -651,8 +673,8 @@ function RegionSelector() { {activeView === RegionViewType.COUNTRY && ( {strings('fiat_on_ramp_aggregator.region.region_variation_notice')} @@ -662,7 +684,7 @@ function RegionSelector() { value={searchString} onPressClearButton={clearSearchText} clearButtonProps={{ - iconName: IconName.Close, + iconName: ComponentLibraryIconName.Close, testID: REGION_SELECTOR_TEST_IDS.CLEAR_BUTTON, }} onFocus={scrollToTop} @@ -710,7 +732,7 @@ RegionSelector.navigationOptions = ({ }) => ({ headerLeft: () => ( navigation.goBack()} style={navigationOptionsStyles.headerLeft} diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap b/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap index 2abfdef31fd..4af2248d1dc 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap @@ -337,15 +337,20 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr Payment methods and available tokens may vary based on your region and our providers. @@ -649,13 +654,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🇫🇷 @@ -671,13 +680,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr France @@ -752,13 +765,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🇺🇸 @@ -774,13 +791,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr United States @@ -806,17 +827,18 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr testID="listitemcolumn" > @@ -886,13 +908,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🇨🇦 @@ -908,13 +934,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr Canada @@ -940,17 +970,18 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr testID="listitemcolumn" > @@ -1020,13 +1051,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🏳️ @@ -1042,13 +1077,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr Unsupported Country @@ -1412,15 +1451,20 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -1724,13 +1768,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🇫🇷 @@ -1746,13 +1794,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` France @@ -1827,13 +1879,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🇺🇸 @@ -1849,13 +1905,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` United States @@ -1881,17 +1941,18 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` testID="listitemcolumn" > @@ -1961,13 +2022,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🇨🇦 @@ -1983,13 +2048,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` Canada @@ -2015,17 +2084,18 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` testID="listitemcolumn" > @@ -2095,13 +2165,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🏳️ @@ -2117,13 +2191,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` Unsupported Country @@ -2487,15 +2565,20 @@ exports[`RegionSelector displays empty state when search has no results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -2693,13 +2776,17 @@ exports[`RegionSelector displays empty state when search has no results 1`] = ` No region matches @@ -3057,15 +3144,20 @@ exports[`RegionSelector displays grouped search results showing country and matc Payment methods and available tokens may vary based on your region and our providers. @@ -3364,13 +3456,17 @@ exports[`RegionSelector displays grouped search results showing country and matc 🇺🇸 @@ -3386,13 +3482,17 @@ exports[`RegionSelector displays grouped search results showing country and matc United States @@ -3418,17 +3518,18 @@ exports[`RegionSelector displays grouped search results showing country and matc testID="listitemcolumn" > @@ -3497,13 +3598,17 @@ exports[`RegionSelector displays grouped search results showing country and matc California @@ -3868,15 +3973,20 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -4144,13 +4254,17 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` 🇩🇪 @@ -4166,13 +4280,17 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` Germany @@ -4536,15 +4654,20 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -4843,13 +4966,17 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` 🇺🇸 @@ -4865,13 +4992,17 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` United States @@ -4897,17 +5028,18 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` testID="listitemcolumn" > @@ -4976,13 +5108,17 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` Texas @@ -5347,15 +5483,20 @@ exports[`RegionSelector does not highlight country when regionCode does not matc Payment methods and available tokens may vary based on your region and our providers. @@ -5679,13 +5820,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🇫🇷 @@ -5701,13 +5846,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc France @@ -5782,13 +5931,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🇺🇸 @@ -5804,13 +5957,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc United States @@ -5836,17 +5993,18 @@ exports[`RegionSelector does not highlight country when regionCode does not matc testID="listitemcolumn" > @@ -5916,13 +6074,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🇨🇦 @@ -5938,13 +6100,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc Canada @@ -5970,17 +6136,18 @@ exports[`RegionSelector does not highlight country when regionCode does not matc testID="listitemcolumn" > @@ -6050,13 +6217,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🏳️ @@ -6072,13 +6243,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc Unsupported Country @@ -6686,13 +6861,17 @@ exports[`RegionSelector does not highlight state when country does not match 1`] California @@ -6766,13 +6945,17 @@ exports[`RegionSelector does not highlight state when country does not match 1`] New York @@ -7380,13 +7563,17 @@ exports[`RegionSelector does not highlight state when state ID does not match 1` California @@ -7460,13 +7647,17 @@ exports[`RegionSelector does not highlight state when state ID does not match 1` New York @@ -8082,13 +8273,17 @@ exports[`RegionSelector does not highlight state when userRegion has no state 1` California @@ -8162,13 +8357,17 @@ exports[`RegionSelector does not highlight state when userRegion has no state 1` New York @@ -8532,15 +8731,20 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu Payment methods and available tokens may vary based on your region and our providers. @@ -8858,13 +9062,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu 🇺🇸 @@ -8880,13 +9088,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu United States @@ -8894,13 +9106,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu CA @@ -8926,17 +9142,18 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu testID="listitemcolumn" > @@ -9020,13 +9237,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu California @@ -9406,15 +9627,20 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -9704,13 +9930,17 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` 🇺🇸 @@ -9726,13 +9956,17 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` United States @@ -9758,17 +9992,18 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` testID="listitemcolumn" > @@ -10128,15 +10363,20 @@ exports[`RegionSelector highlights country when regionCode exactly matches count Payment methods and available tokens may vary based on your region and our providers. @@ -10460,13 +10700,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🇫🇷 @@ -10482,13 +10726,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count France @@ -10578,13 +10826,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🇺🇸 @@ -10600,13 +10852,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count United States @@ -10632,17 +10888,18 @@ exports[`RegionSelector highlights country when regionCode exactly matches count testID="listitemcolumn" > @@ -10712,13 +10969,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🇨🇦 @@ -10734,13 +10995,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count Canada @@ -10766,17 +11031,18 @@ exports[`RegionSelector highlights country when regionCode exactly matches count testID="listitemcolumn" > @@ -10846,13 +11112,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🏳️ @@ -10868,13 +11138,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count Unsupported Country @@ -11238,15 +11512,20 @@ exports[`RegionSelector highlights country when regionCode starts with country c Payment methods and available tokens may vary based on your region and our providers. @@ -11577,13 +11856,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🇫🇷 @@ -11599,13 +11882,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c France @@ -11680,13 +11967,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🇺🇸 @@ -11702,13 +11993,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c United States @@ -11716,13 +12011,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c CA @@ -11748,17 +12047,18 @@ exports[`RegionSelector highlights country when regionCode starts with country c testID="listitemcolumn" > @@ -11843,13 +12143,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🇨🇦 @@ -11865,13 +12169,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c Canada @@ -11897,17 +12205,18 @@ exports[`RegionSelector highlights country when regionCode starts with country c testID="listitemcolumn" > @@ -11977,13 +12286,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🏳️ @@ -11999,13 +12312,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c Unsupported Country @@ -12369,15 +12686,20 @@ exports[`RegionSelector highlights state in grouped search results when parent c Payment methods and available tokens may vary based on your region and our providers. @@ -12703,13 +13025,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c 🇺🇸 @@ -12725,13 +13051,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c United States @@ -12739,13 +13069,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c CA @@ -12771,17 +13105,18 @@ exports[`RegionSelector highlights state in grouped search results when parent c testID="listitemcolumn" > @@ -12865,13 +13200,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c California @@ -13495,13 +13834,17 @@ exports[`RegionSelector highlights state when selected in state view 1`] = ` California @@ -13590,13 +13933,17 @@ exports[`RegionSelector highlights state when selected in state view 1`] = ` New York @@ -13960,15 +14307,20 @@ exports[`RegionSelector limits search results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -14559,13 +14911,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14581,13 +14937,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 0 @@ -14662,13 +15022,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14684,13 +15048,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 1 @@ -14765,13 +15133,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14787,13 +15159,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 2 @@ -14868,13 +15244,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14890,13 +15270,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 3 @@ -14971,13 +15355,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14993,13 +15381,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 4 @@ -15074,13 +15466,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15096,13 +15492,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 5 @@ -15177,13 +15577,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15199,13 +15603,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 6 @@ -15280,13 +15688,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15302,13 +15714,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 7 @@ -15383,13 +15799,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15405,13 +15825,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 8 @@ -15486,13 +15910,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15508,13 +15936,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 9 @@ -16102,13 +16534,17 @@ exports[`RegionSelector navigates back to countries view when back button is pre California @@ -16182,13 +16618,17 @@ exports[`RegionSelector navigates back to countries view when back button is pre New York @@ -16769,13 +17209,17 @@ exports[`RegionSelector navigates to states view when country with states is sel California @@ -16849,13 +17293,17 @@ exports[`RegionSelector navigates to states view when country with states is sel New York @@ -17219,15 +17667,20 @@ exports[`RegionSelector renders countries list 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -17531,13 +17984,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🇫🇷 @@ -17553,13 +18010,17 @@ exports[`RegionSelector renders countries list 1`] = ` France @@ -17634,13 +18095,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🇺🇸 @@ -17656,13 +18121,17 @@ exports[`RegionSelector renders countries list 1`] = ` United States @@ -17688,17 +18157,18 @@ exports[`RegionSelector renders countries list 1`] = ` testID="listitemcolumn" > @@ -17768,13 +18238,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🇨🇦 @@ -17790,13 +18264,17 @@ exports[`RegionSelector renders countries list 1`] = ` Canada @@ -17822,17 +18300,18 @@ exports[`RegionSelector renders countries list 1`] = ` testID="listitemcolumn" > @@ -17902,13 +18381,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🏳️ @@ -17924,13 +18407,17 @@ exports[`RegionSelector renders countries list 1`] = ` Unsupported Country @@ -18294,15 +18781,20 @@ exports[`RegionSelector renders country with supported set to false 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -18529,13 +19021,17 @@ exports[`RegionSelector renders country with supported set to false 1`] = ` 🏳️ @@ -18551,13 +19047,17 @@ exports[`RegionSelector renders country with supported set to false 1`] = ` Unsupported @@ -18921,15 +19421,20 @@ exports[`RegionSelector renders country without flag 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -19164,13 +19669,17 @@ exports[`RegionSelector renders country without flag 1`] = ` United States @@ -19196,17 +19705,18 @@ exports[`RegionSelector renders country without flag 1`] = ` testID="listitemcolumn" > @@ -19565,15 +20075,20 @@ exports[`RegionSelector renders description text only in country view 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -19877,13 +20392,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🇫🇷 @@ -19899,13 +20418,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` France @@ -19980,13 +20503,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🇺🇸 @@ -20002,13 +20529,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` United States @@ -20034,17 +20565,18 @@ exports[`RegionSelector renders description text only in country view 1`] = ` testID="listitemcolumn" > @@ -20114,13 +20646,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🇨🇦 @@ -20136,13 +20672,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` Canada @@ -20168,17 +20708,18 @@ exports[`RegionSelector renders description text only in country view 1`] = ` testID="listitemcolumn" > @@ -20248,13 +20789,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🏳️ @@ -20270,13 +20815,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` Unsupported Country @@ -20857,13 +21406,17 @@ exports[`RegionSelector renders description text only in country view 2`] = ` California @@ -20937,13 +21490,17 @@ exports[`RegionSelector renders description text only in country view 2`] = ` New York @@ -21307,15 +21864,20 @@ exports[`RegionSelector renders disabled country 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -21619,13 +22181,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🇫🇷 @@ -21641,13 +22207,17 @@ exports[`RegionSelector renders disabled country 1`] = ` France @@ -21722,13 +22292,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🇺🇸 @@ -21744,13 +22318,17 @@ exports[`RegionSelector renders disabled country 1`] = ` United States @@ -21776,17 +22354,18 @@ exports[`RegionSelector renders disabled country 1`] = ` testID="listitemcolumn" > @@ -21856,13 +22435,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🇨🇦 @@ -21878,13 +22461,17 @@ exports[`RegionSelector renders disabled country 1`] = ` Canada @@ -21910,17 +22497,18 @@ exports[`RegionSelector renders disabled country 1`] = ` testID="listitemcolumn" > @@ -21990,13 +22578,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🏳️ @@ -22012,13 +22604,17 @@ exports[`RegionSelector renders disabled country 1`] = ` Unsupported Country @@ -22599,13 +23195,17 @@ exports[`RegionSelector renders disabled state 1`] = ` California @@ -22679,13 +23279,17 @@ exports[`RegionSelector renders disabled state 1`] = ` Texas @@ -23049,15 +23653,20 @@ exports[`RegionSelector renders error state when countries error occurs 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -23214,15 +23823,20 @@ exports[`RegionSelector renders error state when countries error occurs 1`] = ` Error @@ -23230,13 +23844,17 @@ exports[`RegionSelector renders error state when countries error occurs 1`] = ` Failed to fetch countries @@ -23594,15 +24212,20 @@ exports[`RegionSelector renders loading state when regions are loading 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -24113,15 +24736,20 @@ exports[`RegionSelector renders recommended countries 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -24365,13 +24993,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` 🇺🇸 @@ -24387,13 +25019,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` United States @@ -24468,13 +25104,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` 🇨🇦 @@ -24490,13 +25130,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` Canada @@ -25069,13 +25713,17 @@ exports[`RegionSelector renders state with supported set to false 1`] = ` Texas @@ -25439,15 +26087,20 @@ exports[`RegionSelector renders state without stateId 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -25682,13 +26335,17 @@ exports[`RegionSelector renders state without stateId 1`] = ` 🇺🇸 @@ -25704,13 +26361,17 @@ exports[`RegionSelector renders state without stateId 1`] = ` United States @@ -25736,17 +26397,18 @@ exports[`RegionSelector renders state without stateId 1`] = ` testID="listitemcolumn" > @@ -26322,13 +26984,17 @@ exports[`RegionSelector renders states view 1`] = ` California @@ -26402,13 +27068,17 @@ exports[`RegionSelector renders states view 1`] = ` New York @@ -26772,15 +27442,20 @@ exports[`RegionSelector renders unsupported country 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -27084,13 +27759,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🇫🇷 @@ -27106,13 +27785,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` France @@ -27187,13 +27870,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🇺🇸 @@ -27209,13 +27896,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` United States @@ -27241,17 +27932,18 @@ exports[`RegionSelector renders unsupported country 1`] = ` testID="listitemcolumn" > @@ -27321,13 +28013,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🇨🇦 @@ -27343,13 +28039,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` Canada @@ -27375,17 +28075,18 @@ exports[`RegionSelector renders unsupported country 1`] = ` testID="listitemcolumn" > @@ -27455,13 +28156,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🏳️ @@ -27477,13 +28182,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` Unsupported Country @@ -28064,13 +28773,17 @@ exports[`RegionSelector renders unsupported state 1`] = ` California @@ -28144,13 +28857,17 @@ exports[`RegionSelector renders unsupported state 1`] = ` Texas @@ -28514,15 +29231,20 @@ exports[`RegionSelector renders when country has states and user region state is Payment methods and available tokens may vary based on your region and our providers. @@ -28853,13 +29575,17 @@ exports[`RegionSelector renders when country has states and user region state is 🇫🇷 @@ -28875,13 +29601,17 @@ exports[`RegionSelector renders when country has states and user region state is France @@ -28956,13 +29686,17 @@ exports[`RegionSelector renders when country has states and user region state is 🇺🇸 @@ -28978,13 +29712,17 @@ exports[`RegionSelector renders when country has states and user region state is United States @@ -28992,13 +29730,17 @@ exports[`RegionSelector renders when country has states and user region state is CA @@ -29024,17 +29766,18 @@ exports[`RegionSelector renders when country has states and user region state is testID="listitemcolumn" > @@ -29119,13 +29862,17 @@ exports[`RegionSelector renders when country has states and user region state is 🇨🇦 @@ -29141,13 +29888,17 @@ exports[`RegionSelector renders when country has states and user region state is Canada @@ -29173,17 +29924,18 @@ exports[`RegionSelector renders when country has states and user region state is testID="listitemcolumn" > @@ -29253,13 +30005,17 @@ exports[`RegionSelector renders when country has states and user region state is 🏳️ @@ -29275,13 +30031,17 @@ exports[`RegionSelector renders when country has states and user region state is Unsupported Country @@ -29645,15 +30405,20 @@ exports[`RegionSelector renders with empty regions array 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -30148,15 +30913,20 @@ exports[`RegionSelector renders with selected user region 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -30487,13 +31257,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🇫🇷 @@ -30509,13 +31283,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` France @@ -30590,13 +31368,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🇺🇸 @@ -30612,13 +31394,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` United States @@ -30626,13 +31412,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` CA @@ -30658,17 +31448,18 @@ exports[`RegionSelector renders with selected user region 1`] = ` testID="listitemcolumn" > @@ -30753,13 +31544,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🇨🇦 @@ -30775,13 +31570,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` Canada @@ -30807,17 +31606,18 @@ exports[`RegionSelector renders with selected user region 1`] = ` testID="listitemcolumn" > @@ -30887,13 +31687,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🏳️ @@ -30909,13 +31713,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` Unsupported Country @@ -31496,13 +32304,17 @@ exports[`RegionSelector resets search when navigating to state view 1`] = ` California @@ -31576,13 +32388,17 @@ exports[`RegionSelector resets search when navigating to state view 1`] = ` New York @@ -31946,15 +32762,20 @@ exports[`RegionSelector scrolls to top when search text changes 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -32152,13 +32973,17 @@ exports[`RegionSelector scrolls to top when search text changes 1`] = ` No region matches @@ -32733,13 +33558,17 @@ exports[`RegionSelector sets up back button in state view 1`] = ` California @@ -32813,13 +33642,17 @@ exports[`RegionSelector sets up back button in state view 1`] = ` New York @@ -33183,15 +34016,20 @@ exports[`RegionSelector shows state name in country view when user has selected Payment methods and available tokens may vary based on your region and our providers. @@ -33532,13 +34370,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🇫🇷 @@ -33554,13 +34396,17 @@ exports[`RegionSelector shows state name in country view when user has selected France @@ -33635,13 +34481,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🇺🇸 @@ -33657,13 +34507,17 @@ exports[`RegionSelector shows state name in country view when user has selected United States @@ -33671,13 +34525,17 @@ exports[`RegionSelector shows state name in country view when user has selected California @@ -33703,17 +34561,18 @@ exports[`RegionSelector shows state name in country view when user has selected testID="listitemcolumn" > @@ -33798,13 +34657,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🇨🇦 @@ -33820,13 +34683,17 @@ exports[`RegionSelector shows state name in country view when user has selected Canada @@ -33852,17 +34719,18 @@ exports[`RegionSelector shows state name in country view when user has selected testID="listitemcolumn" > @@ -33932,13 +34800,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🏳️ @@ -33954,13 +34826,17 @@ exports[`RegionSelector shows state name in country view when user has selected Unsupported Country @@ -34324,15 +35200,20 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] Payment methods and available tokens may vary based on your region and our providers. @@ -34593,13 +35474,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] 🇫🇷 @@ -34615,13 +35500,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] France @@ -34696,13 +35585,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] 🇺🇸 @@ -34718,13 +35611,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] United States @@ -34799,13 +35696,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] 🇨🇦 @@ -34821,13 +35722,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] Canada diff --git a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx index 220487a5e9e..7c9348b3835 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx +++ b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx @@ -17,10 +17,12 @@ import TokenNetworkFilterBar from '../../components/TokenNetworkFilterBar'; import TokenListItem from '../../components/TokenListItem'; import { createUnsupportedTokenModalNavigationDetails } from '../Modals/UnsupportedTokenModal/UnsupportedTokenModal'; -import { Box } from '@metamask/design-system-react-native'; -import Text, { +import { + Box, + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; + FontWeight, +} from '@metamask/design-system-react-native'; import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch'; @@ -298,7 +300,7 @@ function TokenSelection() { const renderEmptyList = useCallback( () => ( - + {strings('deposit.token_modal.no_tokens_found', { searchString, })} @@ -365,10 +367,10 @@ function TokenSelection() { - + {strings('deposit.token_modal.error_loading_tokens')} - + {parseUserFacingError( error, strings('deposit.token_modal.error_loading_tokens'), diff --git a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap index 682a8a90658..bc3ba8abc06 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap +++ b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap @@ -377,237 +377,426 @@ exports[`TokenSelection Component displays empty state when no tokens match sear showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Bitcoin - - - + Bitcoin + + + + - + - + > + + - - Solana - - + + Solana + + + @@ -829,13 +1018,17 @@ exports[`TokenSelection Component displays empty state when no tokens match sear No tokens match "Nonexistent Token" @@ -1237,237 +1430,426 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Bitcoin - - - + Bitcoin + + + + - + - + > + + - - Solana - - + + Solana + + + @@ -1849,13 +2231,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USD Coin @@ -1863,13 +2249,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USDC @@ -2151,13 +2541,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena Tether USD @@ -2165,13 +2559,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USDT @@ -2453,13 +2851,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena Bitcoin @@ -2467,13 +2869,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena BTC @@ -2755,13 +3161,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena Ethereum @@ -2769,13 +3179,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena ETH @@ -3057,13 +3471,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USD Coin @@ -3071,13 +3489,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USDC @@ -3567,237 +3989,426 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy showsHorizontalScrollIndicator={false} > - - - All - - - + All + + + + - + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Bitcoin - - - + Bitcoin + + + + - + - + > + + - - Solana - - + + Solana + + + @@ -4179,13 +4790,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USD Coin @@ -4193,13 +4808,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USDC @@ -4481,13 +5100,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy Tether USD @@ -4495,13 +5118,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USDT @@ -4783,13 +5410,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy Bitcoin @@ -4797,13 +5428,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy BTC @@ -5085,13 +5720,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy Ethereum @@ -5099,13 +5738,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy ETH @@ -5387,13 +6030,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USD Coin @@ -5401,13 +6048,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USDC diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx index 289cdd1edf7..d5d05cec9f5 100644 --- a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx @@ -1,18 +1,17 @@ import React, { useCallback, useRef } from 'react'; import { View, Linking } from 'react-native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import styleSheet from './EligibilityFailedModal.styles'; import { useStyles } from '../../../../hooks/useStyles'; @@ -56,13 +55,13 @@ function EligibilityFailedModal() { testID: ELIGIBILITY_FAILED_MODAL_TEST_IDS.CLOSE_BUTTON, }} > - + {strings('fiat_on_ramp_aggregator.eligibility_failed_modal.title')} - + {strings( 'fiat_on_ramp_aggregator.eligibility_failed_modal.description', )} @@ -73,21 +72,21 @@ function EligibilityFailedModal() { ); diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap b/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap index f587090d88e..f31b4bd7174 100644 --- a/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap @@ -351,13 +351,17 @@ exports[`EligibilityFailedModal renders modal with title and description 1`] = ` Eligibility check failed @@ -414,13 +418,17 @@ exports[`EligibilityFailedModal renders modal with title and description 1`] = ` We couldn't confirm access based on your region. Please try again. If the issue continues, contact support. @@ -435,80 +443,176 @@ exports[`EligibilityFailedModal renders modal with title and description 1`] = ` } } > - Contact support - - + Got it - + diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx b/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx index 6c300ee55ae..4b613685ab6 100644 --- a/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx +++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx @@ -4,7 +4,7 @@ import { render, fireEvent } from '@testing-library/react-native'; // Internal dependencies. import MenuItem from './MenuItem'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { IconName } from '@metamask/design-system-react-native'; const createTestProps = (overrides = {}) => ({ iconName: IconName.Add, diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx b/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx index 6807f519064..798ee9e0b71 100644 --- a/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx +++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx @@ -1,18 +1,20 @@ import React from 'react'; -import Icon, { +import { + Icon, IconName, IconSize, -} from '../../../../../component-library/components/Icons/Icon'; + IconColor, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './MenuItem.styles'; import ListItemColumn, { WidthType, } from '../../../../../component-library/components/List/ListItemColumn'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; interface MenuItemProps { iconName: IconName; @@ -27,7 +29,7 @@ export default function MenuItem({ description, onPress, }: MenuItemProps) { - const { theme, styles } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); return ( - {title} + + {title} + {description && ( - + {description} )} diff --git a/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap b/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap index dd4fbeed459..3a9e3b08f97 100644 --- a/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap +++ b/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap @@ -40,17 +40,18 @@ exports[`MenuItem renders snapshot correctly 1`] = ` testID="listitemcolumn" > Test Menu Item @@ -87,13 +92,17 @@ exports[`MenuItem renders snapshot correctly 1`] = ` Test description @@ -144,17 +153,18 @@ exports[`MenuItem renders with different icon 1`] = ` testID="listitemcolumn" > Test Menu Item @@ -191,13 +205,17 @@ exports[`MenuItem renders with different icon 1`] = ` Test description @@ -248,17 +266,18 @@ exports[`MenuItem renders with empty description 1`] = ` testID="listitemcolumn" > Test Menu Item @@ -338,17 +361,18 @@ exports[`MenuItem renders with title only 1`] = ` testID="listitemcolumn" > Test Menu Item diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx index 535e1877def..acc26334130 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx +++ b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx @@ -1,20 +1,16 @@ import React from 'react'; import { TouchableOpacity, View } from 'react-native'; import { - IconColor as DsIconColor, - IconSize as DsIconSize, -} from '@metamask/design-system-react-native'; -import { Spinner } from '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs'; - -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../component-library/components/Icons/Icon'; -import Text, { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + FontWeight, + Spinner, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './PaymentMethodPill.styles'; import { PAYMENT_METHOD_PILL_TEST_IDS } from './PaymentMethodPill.testIds'; @@ -36,14 +32,14 @@ const PaymentMethodPill: React.FC = ({ isLoading = false, testID = PAYMENT_METHOD_PILL_TEST_IDS.CONTAINER, }) => { - const { styles } = useStyles(styleSheet); + const { styles } = useStyles(styleSheet, {}); if (isLoading) { return ( ); @@ -60,17 +56,21 @@ const PaymentMethodPill: React.FC = ({ - + {label} diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap b/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap index ccaa168c104..a03dffcb9dd 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap +++ b/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap @@ -23,31 +23,37 @@ exports[`PaymentMethodPill matches snapshot 1`] = ` } > Debit card @@ -60,17 +66,18 @@ exports[`PaymentMethodPill matches snapshot 1`] = ` } > @@ -95,5 +102,49 @@ exports[`PaymentMethodPill when isLoading is true matches snapshot when loading ] } testID="payment-method-pill" -/> +> + + + + + + `; diff --git a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx index 448a4457a00..620ee0671d4 100644 --- a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx +++ b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx @@ -6,7 +6,7 @@ import { ButtonVariant, ButtonSize, } from '@metamask/design-system-react-native'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './QuickAmounts.styles'; import { QUICK_AMOUNTS_TEST_IDS } from './QuickAmounts.testIds'; diff --git a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx index b656ef5c0aa..32481953f5b 100644 --- a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx +++ b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx @@ -1,18 +1,17 @@ import React, { useCallback, useRef } from 'react'; -import { Box } from '@metamask/design-system-react-native'; -import Text, { +import { + Box, + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; @@ -45,13 +44,13 @@ function RampUnsupportedModal() { testID: RAMP_UNSUPPORTED_MODAL_TEST_IDS.CLOSE_BUTTON, }} > - + {strings('fiat_on_ramp_aggregator.unsupported_region_modal.title')} - + {strings( 'fiat_on_ramp_aggregator.unsupported_region_modal.description', )} @@ -62,12 +61,11 @@ function RampUnsupportedModal() { ); diff --git a/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap b/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap index 9a41efb4d40..d2f4118d737 100644 --- a/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap +++ b/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap @@ -351,13 +351,17 @@ exports[`RampUnsupportedModal renders modal with title and description 1`] = ` Unavailable in your region @@ -419,13 +423,17 @@ exports[`RampUnsupportedModal renders modal with title and description 1`] = ` Buying crypto isn't available in your region due to limitations with local payment providers or regulatory restrictions. @@ -445,42 +453,90 @@ exports[`RampUnsupportedModal renders modal with title and description 1`] = ` ] } > - Got it - + diff --git a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx index 248cb3485a2..35912fc0ad5 100644 --- a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx @@ -11,11 +11,11 @@ import BadgeNetwork from '../../../../../component-library/components/Badges/Bad import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Text, { +import { + Text, TextColor, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { + FontWeight, ButtonIcon, ButtonIconSize, IconName, @@ -28,7 +28,7 @@ interface TokenListItemProps { token: DepositCryptoCurrency; isSelected?: boolean; onPress: () => void; - textColor?: string; + textColor?: TextColor; isDisabled?: boolean; onInfoPress?: () => void; } @@ -37,7 +37,7 @@ function TokenListItem({ token, isSelected, onPress, - textColor = TextColor.Alternative, + textColor = TextColor.TextAlternative, isDisabled = false, onInfoPress, }: Readonly) { @@ -78,8 +78,14 @@ function TokenListItem({ - {token.name} - + + {token.name} + + {token.symbol} diff --git a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap index af0ec048537..f56cd39ab4d 100644 --- a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap @@ -186,13 +186,17 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` Ethereum @@ -200,13 +204,17 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` ETH @@ -403,13 +411,17 @@ exports[`TokenListItem basic rendering renders disabled token with info button a Ethereum @@ -417,13 +429,17 @@ exports[`TokenListItem basic rendering renders disabled token with info button a ETH diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts index d00e96a7337..8e8edea970f 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts @@ -7,9 +7,6 @@ const styleSheet = () => flexDirection: 'row', gap: 8, }, - selectedNetworkIcon: { - marginRight: 8, - }, }); export default styleSheet; diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx index 5bdf16a6bcc..e50e0e07398 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx @@ -4,14 +4,14 @@ import { ScrollView } from 'react-native-gesture-handler'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import AvatarNetwork from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarNetwork'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; -import Text, { + ButtonVariant, + Text, TextColor, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; +} from '@metamask/design-system-react-native'; import styleSheet from './TokenNetworkFilterBar.styles'; @@ -58,19 +58,20 @@ function TokenNetworkFilterBar({ > {networks.map((chainId) => { const isSelected = !isAllSelected && (networkFilter?.includes(chainId) ?? false); @@ -81,28 +82,27 @@ function TokenNetworkFilterBar({ ); })} diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap b/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap index 826946d851b..f96c4d3946b 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap @@ -18,237 +18,426 @@ exports[`TokenNetworkFilterBar renders correctly with all networks selected (emp showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Optimism - - - + Optimism + + + + - + - + > + + - - Polygon - - + + Polygon + + + `; @@ -271,237 +460,426 @@ exports[`TokenNetworkFilterBar renders correctly with all networks selected (nul showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Optimism - - - + Optimism + + + + - + - + > + + - - Polygon - - + + Polygon + + + `; @@ -524,237 +902,426 @@ exports[`TokenNetworkFilterBar renders correctly with single network selected 1` showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Optimism - - - + Optimism + + + + - + - + > + + - - Polygon - - + + Polygon + + + `; diff --git a/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx b/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx index c254ff5dae7..b66fa4019c8 100644 --- a/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx +++ b/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx @@ -7,15 +7,16 @@ import { type TextLayoutEventData, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, + type TextProps, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../component-library/components/Icons/Icon'; +} from '@metamask/design-system-react-native'; import { createErrorDetailsModalNavDetails } from '../../Views/Modals/ErrorDetailsModal/ErrorDetailsModal'; import { strings } from '../../../../../../locales/i18n'; @@ -96,11 +97,11 @@ const TruncatedError: React.FC = ({ return ( {hasMeasured && isTruncated @@ -113,7 +114,11 @@ const TruncatedError: React.FC = ({ accessibilityRole="button" accessibilityLabel="View error details" > - + ); diff --git a/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap b/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap index a70aab91d4e..935b3573be8 100644 --- a/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap +++ b/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap @@ -17,19 +17,24 @@ exports[`TruncatedError Basic rendering renders an empty error string 1`] = ` numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } /> @@ -79,19 +85,24 @@ exports[`TruncatedError Basic rendering renders correctly and matches snapshot 1 numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > This is a test error message @@ -110,17 +121,18 @@ exports[`TruncatedError Basic rendering renders correctly and matches snapshot 1 onPress={[Function]} > @@ -143,19 +155,24 @@ exports[`TruncatedError Basic rendering renders with custom maxLines prop 1`] = numberOfLines={3} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > This is a test error message @@ -174,17 +191,18 @@ exports[`TruncatedError Basic rendering renders with custom maxLines prop 1`] = onPress={[Function]} > @@ -207,17 +225,22 @@ exports[`TruncatedError Info icon visibility displays the error text after measu numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "1": false, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + false, + ], + ] } > Short error message @@ -236,17 +259,18 @@ exports[`TruncatedError Info icon visibility displays the error text after measu onPress={[Function]} > @@ -269,17 +293,22 @@ exports[`TruncatedError Truncation behavior shows fallback text when error is tr numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "1": false, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + false, + ], + ] } > We've encountered an error @@ -298,17 +327,18 @@ exports[`TruncatedError Truncation behavior shows fallback text when error is tr onPress={[Function]} > diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index f7a8b7c3449..e7341481f55 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -161,6 +161,14 @@ jest.mock( }), ); +jest.mock('../../../selectors/featureFlagController/whatsHappening', () => ({ + selectWhatsHappeningEnabled: jest.fn(() => false), +})); + +jest.mock('../../../selectors/featureFlagController/socialLeaderboard', () => ({ + selectSocialLeaderboardEnabled: jest.fn(() => false), +})); + /** Shape of first argument to useHomeViewedEvent (for asserting in tests). */ interface UseHomeViewedEventParamsSnapshot { sectionName?: string; @@ -178,6 +186,7 @@ jest.mock('./hooks/useHomeViewedEvent', () => ({ HomeSectionNames: { CASH: 'cash', TOKENS: 'tokens', + TOP_TRADERS: 'top_traders', WHATS_HAPPENING: 'whats_happening', PERPS: 'perps', DEFI: 'defi', @@ -269,6 +278,12 @@ describe('Homepage', () => { '../../../selectors/featureFlagController/assetsDefiPositions', ) .selectAssetsDefiPositionsEnabled.mockReturnValue(true); + jest + .requireMock('../../../selectors/featureFlagController/whatsHappening') + .selectWhatsHappeningEnabled.mockReturnValue(false); + jest + .requireMock('../../../selectors/featureFlagController/socialLeaderboard') + .selectSocialLeaderboardEnabled.mockReturnValue(false); }); it('calls enableAllPopularNetworks when Homepage is focused (useFocusEffect)', () => { @@ -391,6 +406,44 @@ describe('Homepage', () => { }); }); + describe("section indices — Social Leaderboard and What's Happening enabled", () => { + beforeEach(() => { + jest + .requireMock('../../../selectors/featureFlagController/whatsHappening') + .selectWhatsHappeningEnabled.mockReturnValue(true); + jest + .requireMock( + '../../../selectors/featureFlagController/socialLeaderboard', + ) + .selectSocialLeaderboardEnabled.mockReturnValue(true); + }); + + it('passes correct sectionIndex including top_traders and shifts following sections', () => { + renderWithProvider(, { state: stateWithPreferences }); + + const calls = getUseHomeViewedEventCalls(); + const callBySectionName = (name: string) => + calls.find((c) => c[0]?.sectionName === name)?.[0]; + + expect(callBySectionName('tokens')?.sectionIndex).toBe(0); + expect(callBySectionName('top_traders')?.sectionIndex).toBe(1); + expect(callBySectionName('perps')?.sectionIndex).toBe(2); + expect(callBySectionName('predict')?.sectionIndex).toBe(3); + expect(callBySectionName('whats_happening')?.sectionIndex).toBe(4); + expect(callBySectionName('defi')?.sectionIndex).toBe(5); + expect(callBySectionName('nfts')?.sectionIndex).toBe(6); + }); + + it("passes totalSectionsLoaded=7 when leaderboard and What's Happening flags are on", () => { + renderWithProvider(, { state: stateWithPreferences }); + + const calls = getUseHomeViewedEventCalls(); + calls.forEach((call) => { + expect(call[0]?.totalSectionsLoaded).toBe(7); + }); + }); + }); + describe('section indices — Cash section enabled', () => { beforeEach(() => { jest diff --git a/app/components/Views/Homepage/Homepage.tsx b/app/components/Views/Homepage/Homepage.tsx index ab320538fe7..9916f1a5b05 100644 --- a/app/components/Views/Homepage/Homepage.tsx +++ b/app/components/Views/Homepage/Homepage.tsx @@ -13,6 +13,7 @@ import TokensSection from './Sections/Tokens'; import WhatsHappeningSection from './Sections/WhatsHappening'; import PerpsSection from './Sections/Perpetuals'; import PredictionsSection from './Sections/Predictions'; +import TopTradersSection from './Sections/TopTraders'; import DeFiSection from './Sections/DeFi'; import NFTsSection from './Sections/NFTs'; import { SectionRefreshHandle } from './types'; @@ -21,6 +22,7 @@ import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags'; import { selectAssetsDefiPositionsEnabled } from '../../../selectors/featureFlagController/assetsDefiPositions'; import { selectWhatsHappeningEnabled } from '../../../selectors/featureFlagController/whatsHappening'; +import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; import { selectIsMusdConversionFlowEnabledFlag } from '../../UI/Earn/selectors/featureFlags'; import { useMusdConversionEligibility } from '../../UI/Earn/hooks/useMusdConversionEligibility'; import { HomeSectionNames, HomeSectionName } from './hooks/useHomeViewedEvent'; @@ -38,6 +40,7 @@ const Homepage = forwardRef((_, ref) => { const whatsHappeningSectionRef = useRef(null); const perpsSectionRef = useRef(null); const predictionsSectionRef = useRef(null); + const topTradersSectionRef = useRef(null); const defiSectionRef = useRef(null); const nftsSectionRef = useRef(null); @@ -45,6 +48,7 @@ const Homepage = forwardRef((_, ref) => { const isPredictEnabled = useSelector(selectPredictEnabledFlag); const isDeFiEnabled = useSelector(selectAssetsDefiPositionsEnabled); const isWhatsHappeningEnabled = useSelector(selectWhatsHappeningEnabled); + const isTopTradersEnabled = useSelector(selectSocialLeaderboardEnabled); const isMusdConversionEnabled = useSelector( selectIsMusdConversionFlowEnabledFlag, ); @@ -72,6 +76,10 @@ const Homepage = forwardRef((_, ref) => { [ { name: HomeSectionNames.CASH, enabled: isCashSectionEnabled }, { name: HomeSectionNames.TOKENS, enabled: true }, + { + name: HomeSectionNames.TOP_TRADERS, + enabled: isTopTradersEnabled, + }, { name: HomeSectionNames.PERPS, enabled: isPerpsEnabled }, { name: HomeSectionNames.PREDICT, enabled: isPredictEnabled }, { @@ -86,6 +94,7 @@ const Homepage = forwardRef((_, ref) => { isWhatsHappeningEnabled, isPerpsEnabled, isPredictEnabled, + isTopTradersEnabled, isDeFiEnabled, ], ); @@ -106,6 +115,7 @@ const Homepage = forwardRef((_, ref) => { whatsHappeningSectionRef.current?.refresh(), perpsSectionRef.current?.refresh(), predictionsSectionRef.current?.refresh(), + topTradersSectionRef.current?.refresh(), defiSectionRef.current?.refresh(), nftsSectionRef.current?.refresh(), ]); @@ -129,6 +139,11 @@ const Homepage = forwardRef((_, ref) => { sectionIndex={getSectionIndex(HomeSectionNames.TOKENS)} totalSectionsLoaded={totalSectionsLoaded} /> + { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock( + '../../../../../selectors/featureFlagController/socialLeaderboard', + () => ({ + selectSocialLeaderboardEnabled: jest.fn(() => true), + }), +); + +jest.mock('../../hooks/useHomeViewedEvent', () => ({ + __esModule: true, + default: jest.fn(() => ({ onLayout: jest.fn() })), + HomeSectionNames: { + CASH: 'cash', + TOKENS: 'tokens', + WHATS_HAPPENING: 'whats_happening', + PERPS: 'perps', + DEFI: 'defi', + PREDICT: 'predict', + NFTS: 'nfts', + TOP_TRADERS: 'top_traders', + }, +})); + +const mockSelectSocialLeaderboardEnabled = jest.requireMock( + '../../../../../selectors/featureFlagController/socialLeaderboard', +).selectSocialLeaderboardEnabled; + +const defaultProps = { sectionIndex: 1, totalSectionsLoaded: 3 }; + +describe('TopTradersSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectSocialLeaderboardEnabled.mockImplementation(() => true); + }); + + it('returns null when the feature flag is disabled', () => { + mockSelectSocialLeaderboardEnabled.mockImplementation(() => false); + renderWithProvider(); + expect(screen.queryByTestId('homepage-top-traders-carousel')).toBeNull(); + }); + + it('renders the carousel when the feature flag is enabled', () => { + renderWithProvider(); + expect( + screen.getByTestId('homepage-top-traders-carousel'), + ).toBeOnTheScreen(); + }); + + it('navigates to the Top Traders view when the section header is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Top Traders')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.SOCIAL_LEADERBOARD.VIEW); + }); + + it('exposes refresh via ref and resolves when called', async () => { + const ref = createRef(); + renderWithProvider(); + + expect(ref.current).not.toBeNull(); + await expect(ref.current?.refresh()).resolves.toBeUndefined(); + }); + + it('invokes onLayout from useHomeViewedEvent when the section root lays out', () => { + const mockOnLayout = jest.fn(); + const mockUseHomeViewedEvent = jest.requireMock( + '../../hooks/useHomeViewedEvent', + ).default as jest.Mock; + mockUseHomeViewedEvent.mockReturnValueOnce({ onLayout: mockOnLayout }); + + renderWithProvider(); + const root = screen.getByTestId('homepage-top-traders-section-root'); + fireEvent(root, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 200 } }, + }); + + expect(mockOnLayout).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx new file mode 100644 index 00000000000..1f038664658 --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx @@ -0,0 +1,87 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, +} from 'react'; +import { ScrollView, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Box } from '@metamask/design-system-react-native'; +import SectionHeader from '../../../../../component-library/components-temp/SectionHeader'; +import { SectionRefreshHandle } from '../../types'; +import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import useHomeViewedEvent, { + HomeSectionNames, +} from '../../hooks/useHomeViewedEvent'; + +interface TopTradersSectionProps { + sectionIndex: number; + totalSectionsLoaded: number; +} + +/** + * TopTradersSection — Social leaderboard section on the homepage. + * + * Shows a horizontal carousel of top-performing traders. + * Currently renders an empty placeholder carousel while the data layer is being built. + */ +const TopTradersSection = forwardRef< + SectionRefreshHandle, + TopTradersSectionProps +>(({ sectionIndex, totalSectionsLoaded }, ref) => { + const sectionViewRef = useRef(null); + const tw = useTailwind(); + const navigation = useNavigation(); + const isEnabled = useSelector(selectSocialLeaderboardEnabled); + const title = strings('homepage.sections.top_traders'); + + useImperativeHandle( + ref, + () => ({ + refresh: async () => undefined, + }), + [], + ); + + const { onLayout } = useHomeViewedEvent({ + sectionRef: sectionViewRef, + isLoading: false, + sectionName: HomeSectionNames.TOP_TRADERS, + sectionIndex, + totalSectionsLoaded, + isEmpty: true, + itemCount: 0, + }); + + const handleViewAll = useCallback(() => { + navigation.navigate(Routes.SOCIAL_LEADERBOARD.VIEW as never); + }, [navigation]); + + if (!isEnabled) { + return null; + } + + return ( + + + + + + + ); +}); + +export default TopTradersSection; diff --git a/app/components/Views/Homepage/Sections/TopTraders/index.ts b/app/components/Views/Homepage/Sections/TopTraders/index.ts new file mode 100644 index 00000000000..db462db9b9d --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/index.ts @@ -0,0 +1 @@ +export { default } from './TopTradersSection'; diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts index 6e8cd814180..077be4d3bb7 100644 --- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts +++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts @@ -12,6 +12,7 @@ export const HomeSectionNames = { DEFI: 'defi', PREDICT: 'predict', NFTS: 'nfts', + TOP_TRADERS: 'top_traders', } as const; export type HomeSectionName = diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx new file mode 100644 index 00000000000..12392ace7cb --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import TopTradersView from './TopTradersView'; +import { TopTradersViewSelectorsIDs } from './TopTradersView.testIds'; + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ goBack: mockGoBack }), + }; +}); + +describe('TopTradersView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the container', () => { + renderWithProvider(); + expect( + screen.getByTestId(TopTradersViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the Top Traders title', () => { + renderWithProvider(); + expect(screen.getByText('Top Traders')).toBeOnTheScreen(); + }); + + it('renders the search button', () => { + renderWithProvider(); + expect( + screen.getByTestId(TopTradersViewSelectorsIDs.SEARCH_BUTTON), + ).toBeOnTheScreen(); + }); + + it('calls goBack when the back button is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByTestId(TopTradersViewSelectorsIDs.BACK_BUTTON)); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('handles search button press without error', () => { + renderWithProvider(); + fireEvent.press( + screen.getByTestId(TopTradersViewSelectorsIDs.SEARCH_BUTTON), + ); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts new file mode 100644 index 00000000000..aa6248d4273 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts @@ -0,0 +1,5 @@ +export const TopTradersViewSelectorsIDs = { + CONTAINER: 'top-traders-view-container', + BACK_BUTTON: 'top-traders-view-back-button', + SEARCH_BUTTON: 'top-traders-view-search-button', +}; diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx new file mode 100644 index 00000000000..9a5536287b8 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx @@ -0,0 +1,77 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + ButtonIcon, + ButtonIconSize, + IconName, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; +import { TopTradersViewSelectorsIDs } from './TopTradersView.testIds'; + +/** + * TopTradersView — Social leaderboard detail screen. + * + * Displays top-performing traders across the platform. + * Currently an empty scaffold; content will be added once the data layer is ready. + */ +const TopTradersView = () => { + const navigation = useNavigation(); + const tw = useTailwind(); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleSearchPress = useCallback(() => { + // Search UI will be wired when the leaderboard data layer ships. + }, []); + + return ( + + + + + + + + + {strings('social_leaderboard.top_traders_view.title')} + + + + ); +}; + +export default TopTradersView; diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/index.ts b/app/components/Views/SocialLeaderboard/TopTradersView/index.ts new file mode 100644 index 00000000000..7f3494e0d3e --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/index.ts @@ -0,0 +1 @@ +export { default } from './TopTradersView'; diff --git a/app/components/Views/SocialLeaderboard/index.ts b/app/components/Views/SocialLeaderboard/index.ts new file mode 100644 index 00000000000..2a56f6cad74 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/index.ts @@ -0,0 +1 @@ +export { default as TopTradersView } from './TopTradersView'; diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index d56982b74c8..2315e4f1cfd 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -41,7 +41,6 @@ const TRANSACTION_TYPES_DISABLE_SCROLL = [TransactionType.predictClaim]; const TRANSACTION_TYPES_DISABLE_ALERT_BANNER = [ TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, - TransactionType.perpsWithdraw, TransactionType.predictDeposit, TransactionType.predictWithdraw, ]; diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx index 9417561b8c9..046843e0d0c 100644 --- a/app/components/Views/confirmations/components/footer/footer.tsx +++ b/app/components/Views/confirmations/components/footer/footer.tsx @@ -43,7 +43,6 @@ import { useQRHardwareContext } from '../../context/qr-hardware-context'; const HIDE_FOOTER_BY_DEFAULT_TYPES = [ TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, - TransactionType.perpsWithdraw, TransactionType.predictDeposit, TransactionType.predictWithdraw, TransactionType.musdConversion, diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx index c95c6d58751..da16d172c01 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx @@ -142,26 +142,6 @@ describe('BridgeFeeRow', () => { expect(getByText('$1.23')).toBeOnTheScreen(); }); - it('renders tooltip for perps withdraw', async () => { - const { getByTestId } = render({ - type: TransactionType.perpsWithdraw, - }); - - await act(async () => { - fireEvent.press(getByTestId('info-row-tooltip-open-btn')); - }); - - expect(getByTestId('info-row-tooltip-open-btn')).toBeDefined(); - }); - - it('renders fee for perps withdraw', () => { - const { getByText } = render({ - type: TransactionType.perpsWithdraw, - }); - - expect(getByText('$1.23')).toBeDefined(); - }); - it('renders metamask fee in tooltip', async () => { useTransactionTotalsMock.mockReturnValue({ fees: { diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx index f846fcd1695..8a30fde4ec0 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx @@ -67,16 +67,11 @@ function TransactionFeeRow({ const feeTotalUsd = useMemo(() => { if (!totals?.fees) return ''; - const metaMask = totals.fees.metaMask.usd ?? 0; - const provider = totals.fees.provider.usd; - const sourceNetwork = totals.fees.sourceNetwork.estimate.usd; - const targetNetwork = totals.fees.targetNetwork.usd; - return formatFiat( - new BigNumber(metaMask) - .plus(provider) - .plus(sourceNetwork) - .plus(targetNetwork), + new BigNumber(totals.fees.metaMask.usd ?? 0) + .plus(totals.fees.provider.usd) + .plus(totals.fees.sourceNetwork.estimate.usd) + .plus(totals.fees.targetNetwork.usd), ); }, [totals, formatFiat]); @@ -134,18 +129,13 @@ function Tooltip({ hasTransactionType(transactionMeta, [ TransactionType.predictDeposit, TransactionType.predictWithdraw, - TransactionType.perpsWithdraw, ]) ) { - if (hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw])) { - message = strings('confirm.tooltip.perps_withdraw.transaction_fee'); - } else if ( - hasTransactionType(transactionMeta, [TransactionType.predictWithdraw]) - ) { - message = strings('confirm.tooltip.predict_withdraw.transaction_fee'); - } else { - message = strings('confirm.tooltip.predict_deposit.transaction_fee'); - } + message = hasTransactionType(transactionMeta, [ + TransactionType.predictWithdraw, + ]) + ? strings('confirm.tooltip.predict_withdraw.transaction_fee') + : strings('confirm.tooltip.predict_deposit.transaction_fee'); } if (hasTransactionType(transactionMeta, [TransactionType.musdConversion])) { diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index 09dba85068a..9afea68a95a 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -268,7 +268,7 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current[0].key).toBe(AlertKeys.InsufficientBalance); }); - it('returns empty array if transaction type is predictWithdraw', () => { + it('returns empty array if transaction type ignored', () => { mockUseTransactionMetadataRequest.mockReturnValue({ ...mockTransaction, type: TransactionType.predictWithdraw, @@ -279,17 +279,6 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); - it('returns empty array if transaction type is perpsWithdraw', () => { - mockUseTransactionMetadataRequest.mockReturnValue({ - ...mockTransaction, - type: TransactionType.perpsWithdraw, - } as unknown as TransactionMeta); - - const { result } = renderHook(() => useInsufficientBalanceAlert()); - - expect(result.current).toStrictEqual([]); - }); - it('returns empty array when using pay source amounts', () => { useTransactionPayHasSourceAmountMock.mockReturnValue(true); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index a42c6b09438..813c9eb69e3 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -16,10 +16,7 @@ import { selectUseTransactionSimulations } from '../../../../../selectors/prefer import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; import { useIsTransactionPayLoading } from '../pay/useTransactionPayData'; -const IGNORE_TYPES = [ - TransactionType.perpsWithdraw, - TransactionType.predictWithdraw, -]; +const IGNORE_TYPES = [TransactionType.predictWithdraw]; export const useInsufficientBalanceAlert = ({ ignoreGasFeeToken, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts index 0e8bc5aec45..a814749b891 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts @@ -268,29 +268,6 @@ describe('useTransactionPayMetrics', () => { }); }); - it('includes perps withdraw properties', async () => { - useTransactionPayTokenMock.mockReturnValue({ - payToken: PAY_TOKEN_MOCK, - setPayToken: noop, - } as ReturnType); - - useTransactionPayQuotesMock.mockReturnValue([QUOTE_MOCK]); - - runHook({ type: TransactionType.perpsWithdraw }); - - await act(async () => noop()); - - expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ - id: transactionIdMock, - params: { - properties: expect.objectContaining({ - mm_pay_use_case: 'perps_withdraw', - }), - sensitiveProperties: {}, - }, - }); - }); - it('includes dust property for non-native quote', async () => { useTransactionPayTokenMock.mockReturnValue({ payToken: PAY_TOKEN_MOCK, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts index 77688c7dba1..809bcbd4a08 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts @@ -106,13 +106,6 @@ export function useTransactionPayMetrics() { properties.mm_pay_use_case = 'predict_withdraw'; } - if ( - payToken && - hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw]) - ) { - properties.mm_pay_use_case = 'perps_withdraw'; - } - if (payToken) { const sendingAmountUsd = Number(primaryRequiredToken?.amountUsd ?? '0'); properties.mm_pay_sending_value_usd = sendingAmountUsd; diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts index 51678cbdec8..9da1a56aa96 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; import type { Hex } from '@metamask/utils'; -import { TransactionType } from '@metamask/transaction-controller'; import { useTransactionPayPostQuote } from './useTransactionPayPostQuote'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayWithdraw } from './useTransactionPayWithdraw'; @@ -183,57 +182,4 @@ describe('useTransactionPayPostQuote', () => { expect(setTransactionConfigMock).toHaveBeenCalledTimes(2); }); - - it('sets isHyperliquidSource=true and no refundTo for perpsWithdraw', () => { - useTransactionMetadataRequestMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - txParams: { from: FROM_MOCK }, - type: TransactionType.perpsWithdraw, - } as never); - useTransactionPayWithdrawMock.mockReturnValue({ - isWithdraw: true, - canSelectWithdrawToken: true, - }); - - renderHook(() => useTransactionPayPostQuote()); - - const callback = setTransactionConfigMock.mock.calls[0][1]; - const config = {} as { - isPostQuote?: boolean; - refundTo?: Hex; - isHyperliquidSource?: boolean; - }; - callback(config); - - expect(config.isPostQuote).toBe(true); - expect(config.isHyperliquidSource).toBe(true); - expect(config.refundTo).toBeUndefined(); - expect(computeProxyAddressMock).not.toHaveBeenCalled(); - }); - - it('does not set isHyperliquidSource for non-perps withdrawals', () => { - useTransactionMetadataRequestMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - txParams: { from: FROM_MOCK }, - type: TransactionType.predictWithdraw, - } as never); - useTransactionPayWithdrawMock.mockReturnValue({ - isWithdraw: true, - canSelectWithdrawToken: true, - }); - - renderHook(() => useTransactionPayPostQuote()); - - const callback = setTransactionConfigMock.mock.calls[0][1]; - const config = {} as { - isPostQuote?: boolean; - refundTo?: Hex; - isHyperliquidSource?: boolean; - }; - callback(config); - - expect(config.isPostQuote).toBe(true); - expect(config.refundTo).toBe(PROXY_ADDRESS_MOCK); - expect(config.isHyperliquidSource).toBeUndefined(); - }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts index 2c69db22f97..99100868fb0 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts @@ -1,11 +1,9 @@ import { useEffect, useRef } from 'react'; import Engine from '../../../../../core/Engine'; import { createProjectLogger, type Hex } from '@metamask/utils'; -import { TransactionType } from '@metamask/transaction-controller'; import { useTransactionPayWithdraw } from './useTransactionPayWithdraw'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { computeProxyAddress } from '../../../../UI/Predict/providers/polymarket/safe/utils'; -import { hasTransactionType } from '../../utils/transaction'; const log = createProjectLogger('transaction-pay-post-quote'); @@ -27,9 +25,6 @@ export function useTransactionPayPostQuote(): void { const { canSelectWithdrawToken } = useTransactionPayWithdraw(); const transactionMeta = useTransactionMetadataRequest(); const transactionId = transactionMeta?.id; - const isPerpsWithdraw = hasTransactionType(transactionMeta, [ - TransactionType.perpsWithdraw, - ]); useEffect(() => { if ( @@ -43,44 +38,21 @@ export function useTransactionPayPostQuote(): void { try { const { TransactionPayController } = Engine.context; const from = transactionMeta?.txParams?.from as Hex | undefined; - - // Predict withdrawals refund to the Safe proxy address. - // Perps withdrawals don't use refundTo -- funds go HyperCore -> Relay directly. - const refundTo = isPerpsWithdraw - ? undefined - : from - ? computeProxyAddress(from) - : undefined; + const refundTo = from ? computeProxyAddress(from) : undefined; TransactionPayController.setTransactionConfig(transactionId, (config) => { config.isPostQuote = true; - - if (refundTo) { - config.refundTo = refundTo; - } - - if (isPerpsWithdraw) { - config.isHyperliquidSource = true; - } + config.refundTo = refundTo; }); isSet.current = transactionId; - log('Initialized post-quote transaction', { - transactionId, - refundTo, - isPerpsWithdraw, - }); + log('Initialized post-quote transaction', { transactionId, refundTo }); } catch (error) { log('Error initializing post-quote transaction', { error, transactionId, }); } - }, [ - canSelectWithdrawToken, - isPerpsWithdraw, - transactionId, - transactionMeta?.txParams?.from, - ]); + }, [canSelectWithdrawToken, transactionId, transactionMeta?.txParams?.from]); } diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index ef291228aaa..b2bb0ac1e15 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -21,7 +21,6 @@ import { useMusdConfirmNavigation } from '../../../../UI/Earn/hooks/useMusdConfi const log = createProjectLogger('transaction-confirm'); export const GO_BACK_TYPES = [ - TransactionType.perpsWithdraw, TransactionType.predictClaim, TransactionType.predictDeposit, TransactionType.predictWithdraw, diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts index 5cc60535e67..e92a4bcf3af 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts @@ -445,48 +445,6 @@ describe('useTransactionCustomAmount', () => { expect(result.current.amountFiat).toBe('8642.46'); }); - it('to percentage of perps available balance', async () => { - (Engine.context as Record).PerpsController = { - state: { - accountState: { - availableBalance: '500.00', - }, - }, - }; - - const { result } = runHook({ - transactionMeta: { - type: TransactionType.perpsWithdraw, - }, - }); - - await act(async () => { - result.current.updatePendingAmountPercentage(50); - }); - - expect(result.current.amountFiat).toBe('250'); - }); - - it('returns 0 for perps withdraw when no available balance', async () => { - (Engine.context as Record).PerpsController = { - state: { - accountState: {}, - }, - }; - - const { result } = runHook({ - transactionMeta: { - type: TransactionType.perpsWithdraw, - }, - }); - - await act(async () => { - result.current.updatePendingAmountPercentage(100); - }); - - expect(result.current.amountFiat).toBe('0'); - }); - it('sets isMax to false when percentage is not 100 and isMaxAmount is true', async () => { useTransactionPayIsMaxAmountMock.mockReturnValue(true); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts index 2b705750a9a..a7ea81c8c3f 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -13,13 +13,13 @@ import { useParams } from '../../../../../util/navigation/navUtils'; import { debounce } from 'lodash'; import { hasTransactionType } from '../../utils/transaction'; import { usePredictBalance } from '../../../../UI/Predict/hooks/usePredictBalance'; -import Engine from '../../../../../core/Engine'; import { useTransactionPayIsMaxAmount, useTransactionPayTotals, } from '../pay/useTransactionPayData'; import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { useConfirmationMetricEvents } from '../metrics/useConfirmationMetricEvents'; +import Engine from '../../../../../core/Engine'; export const MAX_LENGTH = 28; const DEBOUNCE_DELAY = 500; @@ -198,12 +198,6 @@ function useTokenBalance(tokenUsdRate: number) { .multipliedBy(tokenUsdRate) .toNumber(); - if (hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw])) { - const perpsState = Engine.context.PerpsController?.state; - const availableBalance = perpsState?.accountState?.availableBalance; - return availableBalance ? parseFloat(availableBalance) : 0; - } - return hasTransactionType(transactionMeta, [TransactionType.predictWithdraw]) ? predictBalanceUsd : payTokenBalanceUsd; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index fc561b30564..6e33be4dfbf 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -337,6 +337,10 @@ const Routes = { ROOT: 'MarketInsights', VIEW: 'MarketInsightsView', }, + SOCIAL_LEADERBOARD: { + ROOT: 'SocialLeaderboard', + VIEW: 'TopTradersView', + }, PREDICT: { ROOT: 'Predict', MARKET_LIST: 'PredictMarketList', diff --git a/app/core/Engine/controllers/predict-controller/index.test.ts b/app/core/Engine/controllers/predict-controller/index.test.ts index d18368cacc3..ba9ebe54b54 100644 --- a/app/core/Engine/controllers/predict-controller/index.test.ts +++ b/app/core/Engine/controllers/predict-controller/index.test.ts @@ -73,6 +73,7 @@ describe('predict controller init', () => { withdrawTransaction: null, selectedPaymentToken: null, accountMeta: {}, + activeBuyOrder: null, }; initRequestMock.persistedState = { diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index 02071fc9773..89918d75251 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -135,7 +135,6 @@ describe('ramps controller init', () => { isLoading: false, error: null, }, - providerAutoSelected: false, providers: { data: [], selected: null, diff --git a/app/core/SnapKeyring/KeyringSnapPermissions.test.ts b/app/core/SnapKeyring/KeyringSnapPermissions.test.ts index a3dbae7a0e2..473cc037125 100644 --- a/app/core/SnapKeyring/KeyringSnapPermissions.test.ts +++ b/app/core/SnapKeyring/KeyringSnapPermissions.test.ts @@ -19,7 +19,6 @@ describe('keyringSnapPermissionsBuilder', () => { subjectCacheLimit: 100, messenger: { registerActionHandler: jest.fn(), - registerMethodActionHandlers: jest.fn(), registerInitialEventPayload: jest.fn(), publish: jest.fn(), // TODO: Replace `any` with type diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts new file mode 100644 index 00000000000..2f98581f924 --- /dev/null +++ b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts @@ -0,0 +1,63 @@ +import { selectSocialLeaderboardEnabled } from '.'; +// eslint-disable-next-line import-x/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('7.72.0'), +})); + +describe('selectSocialLeaderboardEnabled', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + it('returns true when remote flag is enabled and version requirement is met', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: true, minimumVersion: '7.72.0' }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is disabled', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: false, minimumVersion: '7.72.0' }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version requirement is not met', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: true, minimumVersion: '99.0.0' }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is absent', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when remote flag has an invalid shape', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: 'invalid', minimumVersion: 123 }, + }); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.ts b/app/selectors/featureFlagController/socialLeaderboard/index.ts new file mode 100644 index 00000000000..821940ca158 --- /dev/null +++ b/app/selectors/featureFlagController/socialLeaderboard/index.ts @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + VersionGatedFeatureFlag, + validatedVersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; + +export const selectSocialLeaderboardEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.aiSocialLeaderboardEnabled as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 243e84b7d6e..cfac57977e4 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -65,7 +65,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "quotesLastFetched": null, "quotesLoadingStatus": null, "quotesRefreshCount": 0, - "tokenWarnings": [], }, "BridgeStatusController": { "txHistory": {}, @@ -653,7 +652,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "isLoading": false, "selected": null, }, - "providerAutoSelected": false, "providers": { "data": [], "error": null, @@ -886,7 +884,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "quotesLastFetched": null, "quotesLoadingStatus": null, "quotesRefreshCount": 0, - "tokenWarnings": [], }, "BridgeStatusController": { "txHistory": {}, @@ -1474,7 +1471,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "isLoading": false, "selected": null, }, - "providerAutoSelected": false, "providers": { "data": [], "error": null, diff --git a/app/util/smart-transactions/index.test.ts b/app/util/smart-transactions/index.test.ts index 4bf1c7c7973..b45d700462a 100644 --- a/app/util/smart-transactions/index.test.ts +++ b/app/util/smart-transactions/index.test.ts @@ -104,7 +104,7 @@ describe('Smart Transactions utils', () => { }); }); - it('returns empty object if smartTransaction is not found and waitForSmartTransaction is false', async () => { + it('returns is_smart_transaction true if smartTransaction is not found and waitForSmartTransaction is false', async () => { const transactionMeta = { hash: '0x123' } as TransactionMeta; ( smartTransactionsController.getSmartTransactionByMinedTxHash as jest.Mock @@ -116,7 +116,7 @@ describe('Smart Transactions utils', () => { false, controllerMessenger, ); - expect(result).toEqual({}); + expect(result).toEqual({ is_smart_transaction: true }); }); it('returns correct object if smartTransaction is found but statusMetadata is undefined', async () => { diff --git a/app/util/smart-transactions/index.ts b/app/util/smart-transactions/index.ts index 05852ebb2da..4f7965adae3 100644 --- a/app/util/smart-transactions/index.ts +++ b/app/util/smart-transactions/index.ts @@ -44,7 +44,11 @@ export const getSmartTransactionMetricsProperties = async ( await waitForSmartTransactionConfirmationDone(controllerMessenger); } if (!smartTransaction) { - return {}; + // Still mark as smart transaction since this function is only called when + // smart transactions are enabled for the chain. Cancelled/dropped smart + // transactions won't have a mined tx hash, so the lookup above returns + // nothing, but the transaction still went through the smart transaction flow. + return { is_smart_transaction: true }; } if (!smartTransaction?.statusMetadata) { return { is_smart_transaction: true }; diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 9d2aad7e876..cdb627b5918 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -624,8 +624,7 @@ "quotesInitialLoadTime": null, "quotesLastFetched": null, "quotesLoadingStatus": null, - "quotesRefreshCount": 0, - "tokenWarnings": [] + "quotesRefreshCount": 0 }, "BridgeStatusController": { "txHistory": {} @@ -733,7 +732,7 @@ "pendingDeposits": {}, "pendingClaims": {}, "withdrawTransaction": null, - "activeOrder": null, + "activeBuyOrder": null, "selectedPaymentToken": null, "accountMeta": {} }, @@ -749,7 +748,6 @@ "isLoading": false, "error": null }, - "providerAutoSelected": false, "providers": { "data": [], "selected": null, diff --git a/locales/languages/en.json b/locales/languages/en.json index 107c3f6ba42..456f78e0b05 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1011,6 +1011,11 @@ "see_more": "See more" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Perps is not available", "title": "Perps", @@ -2312,6 +2317,7 @@ "cashing_out_subtitle": "Estimated {{time}} seconds", "placing_prediction": "Placing a prediction", "prediction_placed": "Prediction placed", + "prediction_failed": "Failed to place prediction", "order_failed": "Order failed", "payments_made_in_usdc": "All payments are made in USDC", "prediction_insufficient_funds": "Not enough funds. You can use up to {{amount}}.", @@ -2325,6 +2331,7 @@ "order_failed_title": "Order failed", "order_failed_body": "There wasn't enough liquidity at this price. Want to try again?", "try_again": "Try again", + "view": "View", "yes_buy": "Yes, buy", "yes_sell": "Yes, sell" }, @@ -2379,7 +2386,8 @@ "unknown_error": "An unknown error occurred", "order_not_fully_filled": "Failed to fill your order", "buy_order_not_fully_filled": "Not enough shares available at market price to place your order right now.", - "sell_order_not_fully_filled": "There isn't enough demand at market price to cash out right now." + "sell_order_not_fully_filled": "There isn't enough demand at market price to cash out right now.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Pick a winner", @@ -6526,9 +6534,6 @@ "predict_deposit": { "transaction_fee": "We'll swap your tokens for USDC.e on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, - "perps_withdraw": { - "transaction_fee": "MetaMask will swap to your desired token for you. No MetaMask fee applies when you swap to mUSD." - }, "predict_withdraw": { "transaction_fee": "MetaMask will swap to your desired token for you. No MetaMask fee applies when you swap to mUSD." }, @@ -8271,6 +8276,7 @@ "perpetuals": "Perpetuals", "predictions": "Predictions", "whats_happening": "What's happening", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolitical", "macro": "Macro", diff --git a/metro.config.js b/metro.config.js index 508e86d7bd6..019ab6050ce 100644 --- a/metro.config.js +++ b/metro.config.js @@ -49,22 +49,12 @@ module.exports = function (baseConfig) { /** * E2E Metro redirects under tests/module-mocking. - * - PERFORMANCE_TEST_JOB / E2E_USE_SEEDLESS_OAUTH_METRO_MOCK - * - E2E_USE_SEEDLESS_CONTROLLER_METRO_MOCK: seedless-onboarding-controller mock (default ON) - * - E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK: OAuthLoginHandlers mock (default ON) + * Enables both: seedless-onboarding-controller + OAuthLoginHandlers mocks. + * True when IS_TEST / METAMASK_ENVIRONMENT=e2e OR E2E_MOCK_OAUTH. */ - const e2eAllowsSeedlessOAuthMetroMocks = - isE2E && - (process.env.PERFORMANCE_TEST_JOB === 'true' || - process.env.E2E_USE_SEEDLESS_OAUTH_METRO_MOCK !== 'false'); + const isE2EMockOAuth = process.env.E2E_MOCK_OAUTH === 'true'; - const useE2ESeedlessControllerMetroMock = - e2eAllowsSeedlessOAuthMetroMocks && - process.env.E2E_USE_SEEDLESS_CONTROLLER_METRO_MOCK !== 'false'; - - const useE2EOAuthLoginHandlersMetroMock = - e2eAllowsSeedlessOAuthMetroMocks && - process.env.E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK !== 'false'; + const e2eAllowsSeedlessOAuthMetroMocks = isE2E || isE2EMockOAuth; // For less powerful machines, leave room to do other tasks. For instance, // if you have 10 cores but only 16GB, only 3 workers would get used. @@ -169,42 +159,40 @@ module.exports = function (baseConfig) { ), }; } - if (useE2ESeedlessControllerMetroMock) { - if ( - moduleName.endsWith( - 'controllers/seedless-onboarding-controller', - ) || - moduleName.endsWith( - 'controllers/seedless-onboarding-controller/index', - ) || - moduleName === './seedless-onboarding-controller' || - moduleName === '../seedless-onboarding-controller' - ) { - return { - type: 'sourceFile', - filePath: path.resolve( - __dirname, - 'tests/module-mocking/seedless/index.ts', - ), - }; - } + } + if (e2eAllowsSeedlessOAuthMetroMocks) { + if ( + moduleName.endsWith( + 'controllers/seedless-onboarding-controller', + ) || + moduleName.endsWith( + 'controllers/seedless-onboarding-controller/index', + ) || + moduleName === './seedless-onboarding-controller' || + moduleName === '../seedless-onboarding-controller' + ) { + return { + type: 'sourceFile', + filePath: path.resolve( + __dirname, + 'tests/module-mocking/seedless/index.ts', + ), + }; } - if (useE2EOAuthLoginHandlersMetroMock) { - // Skips native Google/Apple UI; tokens still hit auth server (see module mock). - if ( - moduleName.endsWith('OAuthService/OAuthLoginHandlers') || - moduleName.endsWith('OAuthService/OAuthLoginHandlers/index') || - moduleName === './OAuthLoginHandlers' || - moduleName === '../OAuthLoginHandlers' - ) { - return { - type: 'sourceFile', - filePath: path.resolve( - __dirname, - 'tests/module-mocking/oauth/OAuthLoginHandlers/index.ts', - ), - }; - } + // Skips native Google/Apple UI; tokens still hit auth server (see module mock). + if ( + moduleName.endsWith('OAuthService/OAuthLoginHandlers') || + moduleName.endsWith('OAuthService/OAuthLoginHandlers/index') || + moduleName === './OAuthLoginHandlers' || + moduleName === '../OAuthLoginHandlers' + ) { + return { + type: 'sourceFile', + filePath: path.resolve( + __dirname, + 'tests/module-mocking/oauth/OAuthLoginHandlers/index.ts', + ), + }; } } return context.resolveRequest(context, moduleName, platform); diff --git a/package.json b/package.json index a4248fd12a6..9e3b56428a4 100644 --- a/package.json +++ b/package.json @@ -312,7 +312,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^63.3.0", - "@metamask/transaction-pay-controller": "^19.0.0", + "@metamask/transaction-pay-controller": "^17.1.0", "@metamask/tron-wallet-snap": "1.24.0", "@metamask/utils": "^11.8.1", "@myx-trade/sdk": "^0.1.265", diff --git a/tests/appwright.config.ts b/tests/appwright.config.ts index 677bba370ff..cfd336fde03 100644 --- a/tests/appwright.config.ts +++ b/tests/appwright.config.ts @@ -77,7 +77,7 @@ export default defineConfig({ { name: 'android-onboarding', // Exclude seedless OAuth perf — those run under android-onboarding-seedless with a binary - // built with OAuth Metro mocks enabled (see metro.config.js E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK). + // built with seedless+OAuth Metro mocks testMatch: '**/performance/onboarding/**/*.spec.js', testIgnore: '**/performance/onboarding/seedless-*.spec.js', use: { diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 8294f9e98a0..8e4177ba1c2 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -82,6 +82,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + aiSocialLeaderboardEnabled: { + name: 'aiSocialLeaderboardEnabled', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: { + enabled: false, + minimumVersion: '7.72.0', + }, + status: FeatureFlagStatus.Active, + }, + aiSocialMarketAnalysisEnabled: { name: 'aiSocialMarketAnalysisEnabled', type: FeatureFlagType.Remote, diff --git a/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts b/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts index 7927350094c..4e2a6eddca0 100644 --- a/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts +++ b/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts @@ -13,6 +13,7 @@ import { AuthServerUrl, web3AuthNetwork, } from '../../../../app/core/OAuthService/OAuthLoginHandlers/constants'; +import type { BaseHandlerOptions } from '../../../../app/core/OAuthService/OAuthLoginHandlers/baseHandler'; /** * Login result type @@ -46,6 +47,8 @@ abstract class MockBaseLoginHandler { abstract scope: string[]; abstract authServerPath: string; + public options!: BaseHandlerOptions; + protected authServerUrl: string; protected web3AuthNetwork: string; protected nonce: string; @@ -124,6 +127,11 @@ class MockGoogleLoginHandler extends MockBaseLoginHandler { super(); this.clientId = params.clientId; this.redirectUri = params.redirectUri || 'metamask://'; + this.options = { + clientId: this.clientId, + authServerUrl: this.authServerUrl, + web3AuthNetwork: this.web3AuthNetwork, + }; } async login(): Promise { @@ -171,6 +179,11 @@ class MockAppleLoginHandler extends MockBaseLoginHandler { constructor(params: { clientId: string }) { super(); this.clientId = params.clientId; + this.options = { + clientId: this.clientId, + authServerUrl: this.authServerUrl, + web3AuthNetwork: this.web3AuthNetwork, + }; } async login(): Promise { diff --git a/yarn.lock b/yarn.lock index 1066b35803a..6e5d302e800 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7587,31 +7587,6 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/account-tree-controller@npm:7.0.0" - dependencies: - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - fast-deep-equal: "npm:^3.1.3" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/d5383892f0962e7ea9d6215992a0a7de918cdf4009cc53f51baa0dae3d4f4e428d3ca3a6931e0919b3ac9ac9d1f1ec7f3ecae211bdafca00c34b1edd35046e27 - languageName: node - linkType: hard - "@metamask/accounts-controller@npm:37.0.0": version: 37.0.0 resolution: "@metamask/accounts-controller@npm:37.0.0" @@ -7642,15 +7617,15 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@npm:^7.0.1, @metamask/address-book-controller@npm:^7.1.0, @metamask/address-book-controller@npm:^7.1.1": - version: 7.1.1 - resolution: "@metamask/address-book-controller@npm:7.1.1" +"@metamask/address-book-controller@npm:^7.0.1, @metamask/address-book-controller@npm:^7.1.0": + version: 7.1.0 + resolution: "@metamask/address-book-controller@npm:7.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/0e61958eab6c7f1472d270156398c819c648c9a3a093ed63abaadd7554330befa8149b929c8cb803f4b959fd0ceb71b42bf67b8680ea296f5e224df9b0216b44 + checksum: 10/0c2feddcfcd16e535bc6af2268917a8327ad4c54f6ae6c6df4cfe1ddcda2045e5984ae42e8cb7b9a32e7b5604bfcc70c72015b3756fe9773cb2d18542d33f5b4 languageName: node linkType: hard @@ -7707,113 +7682,92 @@ __metadata: languageName: node linkType: hard -"@metamask/approval-controller@npm:^9.0.0, @metamask/approval-controller@npm:^9.0.1": - version: 9.0.1 - resolution: "@metamask/approval-controller@npm:9.0.1" +"@metamask/approval-controller@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/approval-controller@npm:9.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" nanoid: "npm:^3.3.8" - checksum: 10/980e7ded7022a887c11693226922f9814d160c93fe5297380addafebe9b6e9191ba3acc7bf54775c8c8eeb7e07bcfcaaf79cc90361ff18fa04c1d449eab2ed33 + checksum: 10/3eea0d1f291c159f096ed74d029531af529dc1e94bf1246ce3718bf91c11510fb3a52348eae5547b18af799213beee48f3cfe7d701909e9e527d6d4fe33e0152 languageName: node linkType: hard -"@metamask/assets-controller@npm:^3.0.0, @metamask/assets-controller@npm:^3.2.1": - version: 3.2.1 - resolution: "@metamask/assets-controller@npm:3.2.1" +"@metamask/assets-controller@npm:^2.4.0": + version: 2.4.0 + resolution: "@metamask/assets-controller@npm:2.4.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "npm:^103.0.0" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/client-controller": "npm:^1.0.1" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/assets-controllers": "npm:^101.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/client-controller": "npm:^1.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.2.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" + "@metamask/core-backend": "npm:^6.1.1" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/keyring-internal-api": "npm:^10.0.0" "@metamask/keyring-snap-client": "npm:^8.2.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/network-enablement-controller": "npm:^5.0.1" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/preferences-controller": "npm:^23.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/017cb5d9546e468ad9890686c077d062fbf68a5a28f49f5fab091e9fbdd51b26d61d2b8f171fd04e9fbdd7e8eea2317d17b101c05244c91fd935afddbbf21102 + checksum: 10/7c9d489736617508de3464539f1d2370b720c83d0b62e17ddd9593f8de0fbe2f1f97c65ff86c76793114b79f209c25d975ab169adf1a256ac5116d9f26e86db0 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^101.0.1": - version: 101.0.1 - resolution: "@metamask/assets-controllers@npm:101.0.1" +"@metamask/assets-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/assets-controller@npm:3.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^5.0.1" - "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/assets-controllers": "npm:^101.0.1" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/client-controller": "npm:^1.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/core-backend": "npm:^6.2.0" - "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/keyring-internal-api": "npm:^10.0.0" + "@metamask/keyring-snap-client": "npm:^8.2.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^7.1.0" "@metamask/network-controller": "npm:^30.0.0" "@metamask/network-enablement-controller": "npm:^5.0.0" "@metamask/permission-controller": "npm:^12.2.1" - "@metamask/phishing-controller": "npm:^17.0.0" "@metamask/polling-controller": "npm:^16.0.3" "@metamask/preferences-controller": "npm:^23.0.0" - "@metamask/profile-sync-controller": "npm:^28.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/storage-service": "npm:^1.0.0" "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" + bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/183443bcc72fa08eabda0a7c7d853bc97fb18afb89fca9440fce4fcdd7bf5d34dfdaa3de7ea0497a9c5312faaf60565ad67f23ff2c5166ed732ba523701659e9 + p-limit: "npm:^3.1.0" + checksum: 10/8f5984c11b3efa899871a79d5017475c4622a9dd23a1a8983122dc6c75b46089d71b40808d7a7b29cb8acfa3c476bbfa8c49abecfbd64e11c7f82bfc790ba0d2 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^103.0.0": - version: 103.0.0 - resolution: "@metamask/assets-controllers@npm:103.0.0" +"@metamask/assets-controllers@npm:^101.0.0, @metamask/assets-controllers@npm:^101.0.1": + version: 101.0.1 + resolution: "@metamask/assets-controllers@npm:101.0.1" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7822,32 +7776,32 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^7.0.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.2.1" + "@metamask/core-backend": "npm:^6.2.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/network-enablement-controller": "npm:^5.0.1" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/phishing-controller": "npm:^17.1.0" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/multichain-account-service": "npm:^7.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/phishing-controller": "npm:^17.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/preferences-controller": "npm:^23.0.0" + "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/storage-service": "npm:^1.0.1" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/storage-service": "npm:^1.0.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7863,7 +7817,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/dfa1e0dcf91f94f365046086915aa52f004b43550bdd9f4c97f7a32da7a554f8c091c5d6457384710ea7b5f263720782b28a4d28c8600267b0f96f49737b72a5 + checksum: 10/183443bcc72fa08eabda0a7c7d853bc97fb18afb89fca9440fce4fcdd7bf5d34dfdaa3de7ea0497a9c5312faaf60565ad67f23ff2c5166ed732ba523701659e9 languageName: node linkType: hard @@ -7918,14 +7872,14 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^9.0.0, @metamask/base-controller@npm:^9.0.1": - version: 9.0.1 - resolution: "@metamask/base-controller@npm:9.0.1" +"@metamask/base-controller@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/base-controller@npm:9.0.0" dependencies: - "@metamask/messenger": "npm:^1.0.0" - "@metamask/utils": "npm:^11.9.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" immer: "npm:^9.0.6" - checksum: 10/bc5052c9a38c21a52003e9a79de1f609ff127d939c87eb7b9ebe01cdf05ce2a9ee8e4635dd96f193e9951983e9554d9381af303fbadaae740445ffb2424698e8 + checksum: 10/27554d34ec85c4b585b87850c90dfeaaf9c7e6430f2ab2fa80a1ec06ccc17641e118afab7ad765a0b7255ffef37bc9f6ca5065d459228a2dc660bc463293310d languageName: node linkType: hard @@ -7949,36 +7903,36 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^69.1.1, @metamask/bridge-controller@npm:^69.2.3": - version: 69.2.3 - resolution: "@metamask/bridge-controller@npm:69.2.3" +"@metamask/bridge-controller@npm:^69.1.1": + version: 69.1.1 + resolution: "@metamask/bridge-controller@npm:69.1.1" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/assets-controller": "npm:^3.2.1" - "@metamask/assets-controllers": "npm:^103.0.0" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/assets-controller": "npm:^2.4.0" + "@metamask/assets-controllers": "npm:^101.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.0.6" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/multichain-network-controller": "npm:^3.0.5" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/9a346e147101c18bf5d0f09695d4405c45f80149e301fa27f92b57a138058127cdd5036818312fe02c71611a10b375aaa19455a81748d96ad6e7b55c6756901e + checksum: 10/fac684a9caac65c336464affd8647d34ab0e4ccdddb3db95ffc741c454732df2eae52db6d9d6980ee04e38bd696d09e4c40fce30911bdea6c51fc5b91cf06320 languageName: node linkType: hard @@ -8005,29 +7959,6 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^70.0.3": - version: 70.0.3 - resolution: "@metamask/bridge-status-controller@npm:70.0.3" - dependencies: - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/bridge-controller": "npm:^69.2.3" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^63.3.1" - "@metamask/utils": "npm:^11.9.0" - bignumber.js: "npm:^9.1.2" - uuid: "npm:^8.3.2" - checksum: 10/d8a553dab473aa904244661253beabbbc936b7323f72d7973b3287fb48711cc19072305a6c9a83419b625bcb008cc471e9d4c510e7dd4ff368c9f75d13176b3b - languageName: node - linkType: hard - "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch": version: 69.0.0 resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch::version=69.0.0&hash=41006d" @@ -8101,13 +8032,13 @@ __metadata: languageName: node linkType: hard -"@metamask/client-controller@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/client-controller@npm:1.0.1" +"@metamask/client-controller@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/client-controller@npm:1.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/7a3db2c30c6217a018a7cc36cb40b0be21ec66d1816327619ca91e843657ea684a2194fd27a6d58cb2f9e6f5736e44277c9eb026e9606dd2ae522be68affc912 + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + checksum: 10/79a81ebae21fbe41cc110c5f8593751aebd64c3df98e23c4ec1b2129fa4d86d301afc3a8aa8ff65fc0c917f65c171aa6c90bce00753ec1378966cfca95f89d9c languageName: node linkType: hard @@ -8135,16 +8066,6 @@ __metadata: languageName: node linkType: hard -"@metamask/connectivity-controller@npm:^0.2.0": - version: 0.2.0 - resolution: "@metamask/connectivity-controller@npm:0.2.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/586c80931341375c713aa5a474725ec88174e6e885d5f75d23b6458b734ec0e912a8c47996bea94a82f9549c9598ba982ac99aaeccac1fd92002c35a38f5397f - languageName: node - linkType: hard - "@metamask/contract-metadata@npm:^2.4.0": version: 2.5.0 resolution: "@metamask/contract-metadata@npm:2.5.0" @@ -8421,22 +8342,22 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^23.0.0, @metamask/eth-json-rpc-middleware@npm:^23.1.0, @metamask/eth-json-rpc-middleware@npm:^23.1.1": - version: 23.1.1 - resolution: "@metamask/eth-json-rpc-middleware@npm:23.1.1" +"@metamask/eth-json-rpc-middleware@npm:^23.0.0, @metamask/eth-json-rpc-middleware@npm:^23.1.0": + version: 23.1.0 + resolution: "@metamask/eth-json-rpc-middleware@npm:23.1.0" dependencies: "@metamask/eth-block-tracker": "npm:^15.0.1" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" + "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/message-manager": "npm:^14.1.1" + "@metamask/json-rpc-engine": "npm:^10.2.1" + "@metamask/message-manager": "npm:^14.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" klona: "npm:^2.0.6" pify: "npm:^5.0.0" safe-stable-stringify: "npm:^2.4.3" - checksum: 10/229859120744e22cd0da50e3af316521ddb1f470dd4e2ae9797145d7694d0355d847f1453a1280788359d5cb5ba78bdfc6b038be85287918e7a530cbaf24d1d4 + checksum: 10/3d122e04edfc2c317b80f465dace316e4201dcc2c9e5d191bd46e62c5cd1396855685a2caed8e6189bb858ac91ddde59afb266b0028e12354e4b61ab499bc3e3 languageName: node linkType: hard @@ -8466,15 +8387,15 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^6.0.0, @metamask/eth-json-rpc-provider@npm:^6.0.1": - version: 6.0.1 - resolution: "@metamask/eth-json-rpc-provider@npm:6.0.1" +"@metamask/eth-json-rpc-provider@npm:^6.0.0": + version: 6.0.0 + resolution: "@metamask/eth-json-rpc-provider@npm:6.0.0" dependencies: - "@metamask/json-rpc-engine": "npm:^10.2.4" + "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.9.0" + "@metamask/utils": "npm:^11.8.1" nanoid: "npm:^3.3.8" - checksum: 10/06078a9e43b02f35387a3ccfe09733c7eeac2a732dee1f1be53254fc05719e230776b8512b13702a178fb692088fc7da46f727c5064550d65f51ac59d44f9d83 + checksum: 10/ab7cf6139af7e5d2f26406c22651d4eb103a1fbc95f7274307a35878ae7ad26d51440b56575401286d5d4e57f4f39690c44d31b4088b64cf87ccf6c2d9322436 languageName: node linkType: hard @@ -8684,16 +8605,16 @@ __metadata: languageName: node linkType: hard -"@metamask/gas-fee-controller@npm:^26.0.2, @metamask/gas-fee-controller@npm:^26.0.3, @metamask/gas-fee-controller@npm:^26.1.0, @metamask/gas-fee-controller@npm:^26.1.1": - version: 26.1.1 - resolution: "@metamask/gas-fee-controller@npm:26.1.1" +"@metamask/gas-fee-controller@npm:^26.0.2, @metamask/gas-fee-controller@npm:^26.0.3, @metamask/gas-fee-controller@npm:^26.1.0": + version: 26.1.0 + resolution: "@metamask/gas-fee-controller@npm:26.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -8701,7 +8622,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - checksum: 10/6697364e4f624fee18c9b195003db1e551c572f63069f824bb9d48c0d968b862e8a9f6df6155cf5cc1227f055c7dce641dbf2cadad4df0ebbab6c9f358ce88f3 + checksum: 10/a376b8a6349461ef1aceda258af6d766832e3e89adde5dc9d0bf95d9624c498e76270ed06fd91a52aeffeb77c4d948fd742f38c721808a77383f8b39e3246359 languageName: node linkType: hard @@ -8759,9 +8680,9 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0, @metamask/json-rpc-engine@npm:^10.2.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4": - version: 10.2.4 - resolution: "@metamask/json-rpc-engine@npm:10.2.4" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0, @metamask/json-rpc-engine@npm:^10.2.1, @metamask/json-rpc-engine@npm:^10.2.2, @metamask/json-rpc-engine@npm:^10.2.3": + version: 10.2.3 + resolution: "@metamask/json-rpc-engine@npm:10.2.3" dependencies: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -8769,7 +8690,7 @@ __metadata: "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" klona: "npm:^2.0.6" - checksum: 10/b207dd2a9a44674c141c2e027c082974464a37beada98a27e80fe59c9bd44e2c2a992edf8a8d7e3ed461fa27ed372c95d4e27df18752b558c10bf540b7fe7bcd + checksum: 10/8895ffcfc0dbf5542476dfd9771cb288feaf6fd7e9628e02c10232b3b8f0feabe3a0ad3e3480e3260a69aaafcf8f58d1d89410e7f43e97a08350b3ec3e767b1d languageName: node linkType: hard @@ -8811,7 +8732,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.4.0, @metamask/keyring-api@npm:^21.5.0, @metamask/keyring-api@npm:^21.6.0": +"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.2.0, @metamask/keyring-api@npm:^21.4.0, @metamask/keyring-api@npm:^21.5.0, @metamask/keyring-api@npm:^21.6.0": version: 21.6.0 resolution: "@metamask/keyring-api@npm:21.6.0" dependencies: @@ -8828,26 +8749,26 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^25.0.0, @metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1": - version: 25.1.1 - resolution: "@metamask/keyring-controller@npm:25.1.1" +"@metamask/keyring-controller@npm:^25.0.0, @metamask/keyring-controller@npm:^25.1.0": + version: 25.1.0 + resolution: "@metamask/keyring-controller@npm:25.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/eth-hd-keyring": "npm:^13.0.0" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/eth-simple-keyring": "npm:^11.0.0" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/keyring-internal-api": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" ethereumjs-wallet: "npm:^1.0.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" ulid: "npm:^2.3.0" - checksum: 10/01fe91c90b12c083dafcb003e9df91e0746ab5b53df4559294006be7483c6bf720396200bc7ae1b6a88cdeb4f9d1478a4ee23816a48f35c50f24f51289d29ea9 + checksum: 10/e81fccb901ea3627b97e725a789832eb1e1f2ae61bfc00eaee6ce5717d65a39c73d6c683c8643b87de1ce6d98db76fc3e60004acb0a4b5ea21f05a404204f708 languageName: node linkType: hard @@ -8862,6 +8783,17 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-internal-api@npm:^9.0.0": + version: 9.1.1 + resolution: "@metamask/keyring-internal-api@npm:9.1.1" + dependencies: + "@metamask/keyring-api": "npm:^21.2.0" + "@metamask/keyring-utils": "npm:^3.1.0" + "@metamask/superstruct": "npm:^3.1.0" + checksum: 10/ab0fb8e153a02d3d0acf739d77356a1c60e0a7bf998dcbba9468f9f231605beaed472d8bff27dc56323d0a2529167336499e23dcad911fa8c3e37999ed14d2d1 + languageName: node + linkType: hard + "@metamask/keyring-internal-snap-client@npm:^9.0.0": version: 9.0.0 resolution: "@metamask/keyring-internal-snap-client@npm:9.0.0" @@ -8931,19 +8863,19 @@ __metadata: languageName: node linkType: hard -"@metamask/message-manager@npm:^14.1.0, @metamask/message-manager@npm:^14.1.1": - version: 14.1.1 - resolution: "@metamask/message-manager@npm:14.1.1" +"@metamask/message-manager@npm:^14.1.0": + version: 14.1.0 + resolution: "@metamask/message-manager@npm:14.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/utils": "npm:^11.9.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" "@types/uuid": "npm:^8.3.0" jsonschema: "npm:^1.4.1" uuid: "npm:^8.3.2" - checksum: 10/1e1ca365378e7ba762a150805121053d0360ae7230818ed48521702e2d7a32bc8233c3ef470c269fd4cbe16454674e328a5ebda22133ffd7b1190b04897e81d4 + checksum: 10/0bbea914096b9213fc16283dfe7e79436f2ea21946bcd717440071b7faf36d19a08fef122d5e91f000c586e7e5e909de99d1d2d0e76c1b1b3d42e45ff78474f3 languageName: node linkType: hard @@ -9028,36 +8960,6 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-service@npm:^8.0.1": - version: 8.0.1 - resolution: "@metamask/multichain-account-service@npm:8.0.1" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/eth-snap-keyring": "npm:^19.0.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/keyring-snap-client": "npm:^8.2.0" - "@metamask/keyring-utils": "npm:^3.1.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - async-mutex: "npm:^0.5.0" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/account-api": ^1.0.0 - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/7ac3c38db8afd47593cd7ed4cc95de99195ccd2b2903281382be83990b2a47fa2f03de77e325c1ea3c7fd96367e3eac20039994d95154d478bebacd61215140a - languageName: node - linkType: hard - "@metamask/multichain-api-client@npm:^0.10.1": version: 0.10.1 resolution: "@metamask/multichain-api-client@npm:0.10.1" @@ -9084,22 +8986,22 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5, @metamask/multichain-network-controller@npm:^3.0.6": - version: 3.0.6 - resolution: "@metamask/multichain-network-controller@npm:3.0.6" +"@metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5": + version: 3.0.5 + resolution: "@metamask/multichain-network-controller@npm:3.0.5" dependencies: - "@metamask/accounts-controller": "npm:^37.1.0" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/keyring-api": "npm:^21.6.0" + "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@solana/addresses": "npm:^2.0.0" lodash: "npm:^4.17.21" - checksum: 10/c7e937851b8c944b30c3eafa7d2cfd8d62df9a0278583933912f3c5e1c971f072c5c7e0882677cfe251efd3885e2a0a547c404bd7c2efcf6757c6601431b42a3 + checksum: 10/d1648a28ff412900e59bf3bab5e020ff4d67e899d0a91cf4447c8e2a3e658438ebf9f78d4625ee29f8ef7f552ee41b6d57525e4bd57f12aa21687bfcfe654ba5 languageName: node linkType: hard @@ -9191,20 +9093,20 @@ __metadata: languageName: node linkType: hard -"@metamask/network-controller@npm:^30.0.0, @metamask/network-controller@npm:^30.0.1": - version: 30.0.1 - resolution: "@metamask/network-controller@npm:30.0.1" +"@metamask/network-controller@npm:^30.0.0": + version: 30.0.0 + resolution: "@metamask/network-controller@npm:30.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/connectivity-controller": "npm:^0.2.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/connectivity-controller": "npm:^0.1.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/eth-block-tracker": "npm:^15.0.1" "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^23.1.1" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" + "@metamask/eth-json-rpc-middleware": "npm:^23.1.0" + "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/json-rpc-engine": "npm:^10.2.2" + "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.9.0" @@ -9215,7 +9117,7 @@ __metadata: reselect: "npm:^5.1.1" uri-js: "npm:^4.4.1" uuid: "npm:^8.3.2" - checksum: 10/8679db3ef1c1b39931c0b9133ff26eb55a4385e55e3519253cb51bed38b0ab46ea2e9112f689a7229f5cf0883135aef64d5719cb28870637d6a7c4f1e714d346 + checksum: 10/3f16a1be8f35995147580c23d5fa977c7ac5e231662ac43a75ea8bedea6b2039261bcfaac399a1a9f3dc310eed1f1f4896dcb0ff634add2597da519432789710 languageName: node linkType: hard @@ -9237,21 +9139,21 @@ __metadata: languageName: node linkType: hard -"@metamask/network-enablement-controller@npm:^5.0.0, @metamask/network-enablement-controller@npm:^5.0.1": - version: 5.0.1 - resolution: "@metamask/network-enablement-controller@npm:5.0.1" +"@metamask/network-enablement-controller@npm:^5.0.0": + version: 5.0.0 + resolution: "@metamask/network-enablement-controller@npm:5.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/multichain-network-controller": "npm:^3.0.6" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/multichain-network-controller": "npm:^3.0.5" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" reselect: "npm:^5.1.1" - checksum: 10/bd674ed536ad001fead7414e95736234bfc9d9aa1756882fd1c6cf331604bc4d5906e92cf5505957657aa85c2224d190b04da74fe02d9eb1f433360122652a70 + checksum: 10/8741db7961c7e4c5a08a46653407b0e147b194b0fc3009fa2959d47b0306c7d1715673211964e34991884f966a61a759d2a3c3d2bf7ca93323661f92d85fb187 languageName: node linkType: hard @@ -9318,22 +9220,22 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1, @metamask/permission-controller@npm:^12.3.0": - version: 12.3.0 - resolution: "@metamask/permission-controller@npm:12.3.0" +"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1": + version: 12.2.1 + resolution: "@metamask/permission-controller@npm:12.2.1" dependencies: - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/json-rpc-engine": "npm:^10.2.3" + "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" immer: "npm:^9.0.6" nanoid: "npm:^3.3.8" - checksum: 10/a5fe9f2bab8c2d41cd829cd6c1af970e71da97eac42de17071c10f90d975e9135a4e6987ed6b2f3ea2209b1c6c51b822508f800225fda2207cdc598c16ea77dd + checksum: 10/610ed3acb63ca256592319c6f775e8888102c06304e46a95faf75abe898f0bf715a6254c6784a3964c0c379082cb7f1d1acfcf7db4af9bae9797f662944c3ebc languageName: node linkType: hard @@ -9354,35 +9256,35 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^17.0.0, @metamask/phishing-controller@npm:^17.1.0": - version: 17.1.0 - resolution: "@metamask/phishing-controller@npm:17.1.0" +"@metamask/phishing-controller@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/phishing-controller@npm:17.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@noble/hashes": "npm:^1.8.0" "@types/punycode": "npm:^2.1.0" ethereum-cryptography: "npm:^2.1.2" fastest-levenshtein: "npm:^1.0.16" punycode: "npm:^2.1.1" - checksum: 10/e320a060b482296e4b8820c98e5266fee9080a31666a1320338c22e95b2aeb785574d523afabc6df9aa516a2e2267ea261e42856030a929f197c9edd36bc57c5 + checksum: 10/a1917ad63feb5c6287b7a191f78750d6455239909b0df5d07a965279638ccccb67de73d2f3cbe5596252e14b394565978bb86aa52e0adf388059d031531d0e93 languageName: node linkType: hard -"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3, @metamask/polling-controller@npm:^16.0.4": - version: 16.0.4 - resolution: "@metamask/polling-controller@npm:16.0.4" +"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3": + version: 16.0.3 + resolution: "@metamask/polling-controller@npm:16.0.3" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/utils": "npm:^11.9.0" "@types/uuid": "npm:^8.3.0" fast-json-stable-stringify: "npm:^2.1.0" uuid: "npm:^8.3.2" - checksum: 10/c656f78f010103c65ae2018e75ef2c51c3b915bc9dd2624bdd7b06a327704a2428d0d0ec78c2570425cc611e7a7b85bfcaa9f0f0d2d16cfbc686e0e9fe3f29c2 + checksum: 10/31182b6d62fa949bf8bee834a65aba819e52ce77c208faebb33f6e3982834e87877e29d2d93952264988ea309d110b003adf69f358dc5820eeab7ab3c09f924f languageName: node linkType: hard @@ -9396,13 +9298,13 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^23.0.0, @metamask/preferences-controller@npm:^23.1.0": - version: 23.1.0 - resolution: "@metamask/preferences-controller@npm:23.1.0" +"@metamask/preferences-controller@npm:^23.0.0": + version: 23.0.0 + resolution: "@metamask/preferences-controller@npm:23.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/61fe1115546ea0c1e45143c2227d7907930ae881c3f14fe6bfb260d9d4e6fdfc84e3f46af69454daeef7dcbb7d02fdb204baf0c29bf05e18212479b6e96721a0 + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + checksum: 10/3dd5aa99cf781ffdc364d577536f009eaa0cdb57e8ae85ae6d311b51a358986194d8f745280518f86f1bc4b41094a89162ffad8b6c544178c0d892fe430ebf10 languageName: node linkType: hard @@ -9457,17 +9359,17 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.0.2": - version: 28.0.2 - resolution: "@metamask/profile-sync-controller@npm:28.0.2" +"@metamask/profile-sync-controller@npm:^28.0.0": + version: 28.0.0 + resolution: "@metamask/profile-sync-controller@npm:28.0.0" dependencies: - "@metamask/address-book-controller": "npm:^7.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/address-book-controller": "npm:^7.0.1" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/utils": "npm:^11.9.0" "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" @@ -9477,7 +9379,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/37cd8032f673436ff7a7b759287731691e864fb94b8a8b16f819de6956652bb51c4b020c1df5213113899feb5c36f03c98a35d8dce3629370725f94964ba5585 + checksum: 10/008f66cea003cbf4d6d8b827daf7e943ff2b1ef9ec7bcbc749a8a57860c410d44e624149a8400e82034055ffcd2a74a760a633592b558188916dfe360b1287df languageName: node linkType: hard @@ -9523,14 +9425,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^12.0.1, @metamask/ramps-controller@npm:^12.1.0": - version: 12.1.0 - resolution: "@metamask/ramps-controller@npm:12.1.0" +"@metamask/ramps-controller@npm:^12.0.1": + version: 12.0.1 + resolution: "@metamask/ramps-controller@npm:12.0.1" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/ae1f3f4cb4dd4493ed4d75220a54ce8e5878b16e3b31dbafdf5ae0256cf3f9b1ef9aad146e61609485c4a682a119fd0fdadc2489862bd6d1c480878f2dc3c409 + "@metamask/messenger": "npm:^0.3.0" + checksum: 10/a7f9428cb824bd0175ee1cc603d77c650fa7a23c7183e2cc0a0f21ee9b6378d80bbd1e496654e40d2edcfc840e60dd4a09d80feacb1746087a67b66761e1e6c7 languageName: node linkType: hard @@ -9616,16 +9518,16 @@ __metadata: languageName: node linkType: hard -"@metamask/remote-feature-flag-controller@npm:^4.0.0, @metamask/remote-feature-flag-controller@npm:^4.1.0, @metamask/remote-feature-flag-controller@npm:^4.2.0": - version: 4.2.0 - resolution: "@metamask/remote-feature-flag-controller@npm:4.2.0" +"@metamask/remote-feature-flag-controller@npm:^4.0.0, @metamask/remote-feature-flag-controller@npm:^4.1.0": + version: 4.1.0 + resolution: "@metamask/remote-feature-flag-controller@npm:4.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" uuid: "npm:^8.3.2" - checksum: 10/bb8d4cf6d90cb895d994d471d53429ed816c2b60aef85f62b0731536cc2822cda3795b16ed5bd77cf87a934bcecda7a021024f4c224d5cda866d717db76d3400 + checksum: 10/30122c316e788adc2abb6875eefef189946e2af469c1b217f8617ade17693666cde896e043fcb2a65874b2e62d4499b05456345dd2425dee6f9ea92f1f2d12e3 languageName: node linkType: hard @@ -9886,49 +9788,6 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^19.0.0": - version: 19.0.0 - resolution: "@metamask/snaps-controllers@npm:19.0.0" - dependencies: - "@metamask/approval-controller": "npm:^9.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/json-rpc-engine": "npm:^10.2.3" - "@metamask/json-rpc-middleware-stream": "npm:^8.0.8" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/object-multiplex": "npm:^2.1.0" - "@metamask/permission-controller": "npm:^12.2.1" - "@metamask/post-message-stream": "npm:^10.0.0" - "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-registry": "npm:^4.0.0" - "@metamask/snaps-rpc-methods": "npm:^15.0.1" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/storage-service": "npm:^1.0.0" - "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.10.0" - "@xstate/fsm": "npm:^2.0.0" - async-mutex: "npm:^0.5.0" - concat-stream: "npm:^2.0.0" - cron-parser: "npm:^4.5.0" - fast-deep-equal: "npm:^3.1.3" - get-npm-tarball-url: "npm:^2.0.3" - immer: "npm:^9.0.21" - luxon: "npm:^3.5.0" - nanoid: "npm:^3.3.10" - readable-stream: "npm:^3.6.2" - readable-web-to-node-stream: "npm:^3.0.2" - semver: "npm:^7.5.4" - tar-stream: "npm:^3.1.7" - peerDependencies: - "@metamask/snaps-execution-environments": ^11.0.2 - peerDependenciesMeta: - "@metamask/snaps-execution-environments": - optional: true - checksum: 10/95d4522877aee8d320ace7de396255a827efab6b63ee81a4dfa34d595d65c3e429d586f6895aa70e170201b907b6bf3c7fb33f5bd683768873f92f58817792d3 - languageName: node - linkType: hard - "@metamask/snaps-execution-environments@npm:^11.0.1": version: 11.0.1 resolution: "@metamask/snaps-execution-environments@npm:11.0.1" @@ -9976,20 +9835,20 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^15.0.0, @metamask/snaps-rpc-methods@npm:^15.0.1": - version: 15.0.1 - resolution: "@metamask/snaps-rpc-methods@npm:15.0.1" +"@metamask/snaps-rpc-methods@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/snaps-rpc-methods@npm:15.0.0" dependencies: "@metamask/key-tree": "npm:^10.1.1" - "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/snaps-utils": "npm:^12.1.1" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.10.0" "@noble/hashes": "npm:^1.7.1" async-mutex: "npm:^0.5.0" - checksum: 10/40353ead6a12def2cb301fd4fc35c8dfb6783fc4d8ebc52ad2b9d6453d64f1c0f69a619d1e3c240250542c44cfea7f2fd0461ff73907c3327588fc1c409b942e + checksum: 10/178db2fa6cc4fced381bc5b034fc2d2ac465d63f143255c53118fb1d3aa98381d88e5c3a87ccfce75bf8689972da3087258734fc83ec9483d4296034fbac21e7 languageName: node linkType: hard @@ -10038,15 +9897,15 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^12.1.0, @metamask/snaps-utils@npm:^12.1.1, @metamask/snaps-utils@npm:^12.1.2": - version: 12.1.2 - resolution: "@metamask/snaps-utils@npm:12.1.2" +"@metamask/snaps-utils@npm:^12.1.0, @metamask/snaps-utils@npm:^12.1.1": + version: 12.1.1 + resolution: "@metamask/snaps-utils@npm:12.1.1" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" - "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.4.0" "@metamask/snaps-registry": "npm:^4.0.0" @@ -10058,14 +9917,14 @@ __metadata: cron-parser: "npm:^4.5.0" fast-deep-equal: "npm:^3.1.3" fast-json-stable-stringify: "npm:^2.1.0" - fast-xml-parser: "npm:^5.5.6" + fast-xml-parser: "npm:^5.3.8" luxon: "npm:^3.5.0" marked: "npm:^12.0.1" rfdc: "npm:^1.3.0" semver: "npm:^7.5.4" ses: "npm:^1.15.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/cf36670f9946e2ab737d7fd1fd5be5c0e915c66b57e76d5f4bfc509061420f79b442b353c52a94c5f953fceba75f910ebd512592956a62e831f4eb43d0b0a40f + checksum: 10/acaefe9d78766e7af7c939a7fd6a80ba4f4d7559862b2594d831d00054eb9c05537332c8f2d912ff5170732aceef42a359431f37794e8f2f4fd23151a1b6e271 languageName: node linkType: hard @@ -10097,13 +9956,13 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@npm:^1.0.0, @metamask/storage-service@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/storage-service@npm:1.0.1" +"@metamask/storage-service@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/storage-service@npm:1.0.0" dependencies: - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/3ec18b85ae80d13c4928be327abb1ee0548a6c44afdb7f709434a6621c876c3de95e145ca2603bdf178772982c76f546ec1cac58f28c0a9c74e020342d171349 + checksum: 10/506b681f9f678102f8dd700d3c0531a35894d2a810431bdbcaaf1089d6dcfdb869ee3118b0375012498ba20e4fe8d2682d2695082268bb1dab3b774c9044d329 languageName: node linkType: hard @@ -10251,9 +10110,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^63.0.0, @metamask/transaction-controller@npm:^63.3.0, @metamask/transaction-controller@npm:^63.3.1": - version: 63.3.1 - resolution: "@metamask/transaction-controller@npm:63.3.1" +"@metamask/transaction-controller@npm:^63.0.0, @metamask/transaction-controller@npm:^63.3.0": + version: 63.3.0 + resolution: "@metamask/transaction-controller@npm:63.3.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -10262,18 +10121,18 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^37.1.0" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.2.1" + "@metamask/core-backend": "npm:^6.2.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" @@ -10286,7 +10145,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/0e661e59a00595258d01a3de9dbee7529899234f2f10d315cbfd92cccfdc692af5c3f573b8dcbe7b2fc05a35e060c9f6b5f573cb38d046657b7fb79a4d24d6dc + checksum: 10/e9616ee54fad77bc5df47f4dd41aad3f423174b505c8a743f0097fdd87d5a6d5d5a3223fca16a940f306d0c4d918c76e345e00a7e76a646a44e00d12f88e15fb languageName: node linkType: hard @@ -10329,32 +10188,31 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^19.0.0": - version: 19.0.0 - resolution: "@metamask/transaction-pay-controller@npm:19.0.0" +"@metamask/transaction-pay-controller@npm:^17.1.0": + version: 17.1.0 + resolution: "@metamask/transaction-pay-controller@npm:17.1.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^3.2.1" - "@metamask/assets-controllers": "npm:^103.0.0" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/bridge-controller": "npm:^69.2.3" - "@metamask/bridge-status-controller": "npm:^70.0.3" + "@metamask/assets-controller": "npm:^2.4.0" + "@metamask/assets-controllers": "npm:^101.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/bridge-status-controller": "npm:^69.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/ramps-controller": "npm:^12.1.0" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/121bbc55501b4e9e9ab633356637e2098ffa0aa8745b768ef04902067629e143cd495d7a222ae0e51f051b956d34349200424337a5cba4d456c2b417cbb0c355 + checksum: 10/79d8cb54d010551c63cd34f5dd9d4d0b3fab3186ae770133a573c528b16598c2421241219801a4c81bf3dc38805c95b5c8a680bfe5d070dece50cf4dac891799 languageName: node linkType: hard @@ -29758,12 +29616,10 @@ __metadata: languageName: node linkType: hard -"fast-xml-builder@npm:^1.1.4": - version: 1.1.4 - resolution: "fast-xml-builder@npm:1.1.4" - dependencies: - path-expression-matcher: "npm:^1.1.3" - checksum: 10/32937866aaf5a90e69d1f4ee6e15e875248d5b5d2afd70277e9e8323074de4980cef24575a591b8e43c29f405d5f12377b3bad3842dc412b0c5c17a3eaee4b6b +"fast-xml-builder@npm:^1.0.0": + version: 1.0.0 + resolution: "fast-xml-builder@npm:1.0.0" + checksum: 10/06c04d80545e5c9f4d1d6cca00567b5cc09953a92c6328fa48cfb4d7f42630313b8c2bb62e9cb81accee7bb5e1c5312fcae06c3d20dbe52d969a5938233316da languageName: node linkType: hard @@ -29778,16 +29634,15 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.5.6": - version: 5.5.9 - resolution: "fast-xml-parser@npm:5.5.9" +"fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.3.8": + version: 5.4.2 + resolution: "fast-xml-parser@npm:5.4.2" dependencies: - fast-xml-builder: "npm:^1.1.4" - path-expression-matcher: "npm:^1.2.0" - strnum: "npm:^2.2.2" + fast-xml-builder: "npm:^1.0.0" + strnum: "npm:^2.1.2" bin: fxparser: src/cli/cli.js - checksum: 10/5f1a1a8b524406af21e9adb24f846b0da6b629c86b1eeedb54757cc293c24ed4f79ff9570b82206265b6951d68acd2dc93e74687ea5d7da0beafa09536cee73f + checksum: 10/12585d5dd77113411d01cf41818cfecbbaf8f3d9e8448b1c35f50a7eb51205408bc8db27af5733173a77f96f72d7e121d9e675674f71334569157c77845aba39 languageName: node linkType: hard @@ -35808,7 +35663,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^63.3.0" - "@metamask/transaction-pay-controller": "npm:^19.0.0" + "@metamask/transaction-pay-controller": "npm:^17.1.0" "@metamask/tron-wallet-snap": "npm:1.24.0" "@metamask/utils": "npm:^11.8.1" "@myx-trade/sdk": "npm:^0.1.265" @@ -38847,13 +38702,6 @@ __metadata: languageName: node linkType: hard -"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.2.0": - version: 1.2.0 - resolution: "path-expression-matcher@npm:1.2.0" - checksum: 10/eab23babd9a97d6cf4841a99825c3e990b70b2b29ea6529df9fb6a1f3953befbc68e9e282a373d7a75aff5dc6542d05a09ee2df036ff9bfddf5e1627b769875b - languageName: node - linkType: hard - "path-extra@npm:^1.0.2": version: 1.0.3 resolution: "path-extra@npm:1.0.3" @@ -44604,10 +44452,10 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.2.2": - version: 2.2.2 - resolution: "strnum@npm:2.2.2" - checksum: 10/c55813cfded750dc84556b4881ffc7cee91382ff15a48f1fba0ff7a678e1640ed96ca40806fbd55724940fd7d51cf752469b2d862e196e4adefb6c7d5d9cd73b +"strnum@npm:^2.1.2": + version: 2.2.0 + resolution: "strnum@npm:2.2.0" + checksum: 10/2969dbc8441f5af1b55db1d2fcea64a8f912de18515b57f85574e66bdb8f30ae76c419cf1390b343d72d687e2aea5aca82390f18b9e0de45d6bcc6d605eb9385 languageName: node linkType: hard