From bc89ae7441f078ec17f4e5009eb3ca63ec15d4e5 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 25 Nov 2025 18:09:58 +0000 Subject: [PATCH 1/9] fix: cp-7.60.0 set default balances polling (#23253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The polling rate for mobile is set to 180s, which is frustratring for users, as they cannot see balances updating at the default 30s that we have for extension and they think something is wrong. Setting the updates to the default 30s is likely to lower the number of tickets with balance issues that we are currently getting. ## **Changelog** CHANGELOG entry: Fixed balance update polling rate ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1111 Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1394 Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1291 Fixes: #23130 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../Engine/controllers/token-balances-controller-init.test.ts | 2 +- app/core/Engine/controllers/token-balances-controller-init.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/core/Engine/controllers/token-balances-controller-init.test.ts b/app/core/Engine/controllers/token-balances-controller-init.test.ts index 1a3e5eb7331d..89d65e9cc5e1 100644 --- a/app/core/Engine/controllers/token-balances-controller-init.test.ts +++ b/app/core/Engine/controllers/token-balances-controller-init.test.ts @@ -52,7 +52,7 @@ describe('TokenBalancesControllerInit', () => { expect(controllerMock).toHaveBeenCalledWith({ messenger: expect.any(Object), state: undefined, - interval: 180_000, + interval: 30_000, allowExternalServices: expect.any(Function), queryMultipleAccounts: expect.any(Boolean), accountsApiChainIds: expect.any(Function), diff --git a/app/core/Engine/controllers/token-balances-controller-init.ts b/app/core/Engine/controllers/token-balances-controller-init.ts index 45ac78aceaf8..d09f90d3d5f9 100644 --- a/app/core/Engine/controllers/token-balances-controller-init.ts +++ b/app/core/Engine/controllers/token-balances-controller-init.ts @@ -24,8 +24,7 @@ export const tokenBalancesControllerInit: ControllerInitFunction< const controller = new TokenBalancesController({ messenger: controllerMessenger, state: persistedState.TokenBalancesController, - // TODO: This is long, can we decrease it? - interval: 180_000, + interval: 30_000, allowExternalServices: () => selectBasicFunctionalityEnabled(getState()), queryMultipleAccounts: preferencesState.isMultiAccountBalancesEnabled, accountsApiChainIds: () => From 955c0ce89b70d2728262b99fa08af45ed9a0caa9 Mon Sep 17 00:00:00 2001 From: George Gkasdrogkas Date: Tue, 25 Nov 2025 20:27:23 +0200 Subject: [PATCH 2/9] refactor: Migrate swap utilities to global scope (#23234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The following utilities are referenced from other parts of the codebase. We either move them into global scope (if multiple references are found) or move them into local scope of the relevant consumer. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3478 ## **Manual testing steps** ```gherkin No business logic is affected, just ensure that there are not regressions in swaps and transaction screen related to swap and bridges. ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Extracts `isSwapsNativeAsset` to `util/bridge` and moves stablecoin slippage hook under Bridge, updating imports and tests across Swaps and Transaction utils. > > - **Refactor**: > - Centralizes `isSwapsNativeAsset` in `app/util/bridge/index.ts`; removes duplicate from `components/UI/Swaps/utils` and updates all consumers (`Swaps` views, utils, transaction utils). > - Relocates stablecoin slippage logic to `components/UI/Bridge/hooks/useStablecoinsDefaultSlippage`; updates `useInitialSlippage` to consume `getIsStablecoinPair` from the new location and fixes import paths. > - **Swaps UI**: > - Updates `QuotesView`, `Swaps` amount view, token utils, and balance hook to import `isSwapsNativeAsset` from `util/bridge` and use the relocated slippage hook. > - **Tests**: > - Adds `app/util/bridge/index.test.ts` for `isSwapsNativeAsset`. > - Updates `useStablecoinsDefaultSlippage` test to new paths. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5e6b5cf249dc1de864033ceaa58aed792b7050fd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Bridge/hooks/useInitialSlippage/index.ts | 2 +- .../index.test.tsx} | 9 +- .../useStablecoinsDefaultSlippage/index.ts} | 6 +- app/components/UI/Swaps/QuotesView.js | 2 +- app/components/UI/Swaps/index.js | 4 +- app/components/UI/Swaps/utils/index.js | 5 +- .../UI/Swaps/utils/token-list-utils.ts | 2 +- app/components/UI/Swaps/utils/useBalance.js | 3 +- app/components/UI/TransactionElement/utils.js | 2 +- app/util/bridge/index.test.ts | 106 ++++++++++++++++++ app/util/bridge/index.ts | 5 + 11 files changed, 126 insertions(+), 20 deletions(-) rename app/components/UI/{Swaps/useStablecoinsDefaultSlippage.test.tsx => Bridge/hooks/useStablecoinsDefaultSlippage/index.test.tsx} (97%) rename app/components/UI/{Swaps/useStablecoinsDefaultSlippage.ts => Bridge/hooks/useStablecoinsDefaultSlippage/index.ts} (96%) create mode 100644 app/util/bridge/index.test.ts create mode 100644 app/util/bridge/index.ts diff --git a/app/components/UI/Bridge/hooks/useInitialSlippage/index.ts b/app/components/UI/Bridge/hooks/useInitialSlippage/index.ts index 269da2ea883c..0e4110c81612 100644 --- a/app/components/UI/Bridge/hooks/useInitialSlippage/index.ts +++ b/app/components/UI/Bridge/hooks/useInitialSlippage/index.ts @@ -8,9 +8,9 @@ import { selectSourceToken, setSlippage, } from '../../../../../core/redux/slices/bridge'; -import { getIsStablecoinPair } from '../../../Swaps/useStablecoinsDefaultSlippage'; import { isHex } from 'viem'; import AppConstants from '../../../../../core/AppConstants'; +import { getIsStablecoinPair } from '../useStablecoinsDefaultSlippage'; export const useInitialSlippage = () => { const dispatch = useDispatch(); diff --git a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx b/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.test.tsx similarity index 97% rename from app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx rename to app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.test.tsx index 87e02062e80f..bd0527878d8c 100644 --- a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx +++ b/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.test.tsx @@ -1,11 +1,8 @@ -import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; -import { - useStablecoinsDefaultSlippage, - handleEvmStablecoinSlippage, -} from './useStablecoinsDefaultSlippage'; +import { useStablecoinsDefaultSlippage, handleEvmStablecoinSlippage } from './'; import { Hex } from '@metamask/utils'; import { CHAIN_IDS } from '@metamask/transaction-controller'; -import AppConstants from '../../../core/AppConstants'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import AppConstants from '../../../../../core/AppConstants'; describe('useStablecoinsDefaultSlippage', () => { const mockSetSlippage = jest.fn(); diff --git a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts b/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts similarity index 96% rename from app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts rename to app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts index 5a6c121eb65c..b9412f4d9f9d 100644 --- a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts +++ b/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts @@ -1,10 +1,10 @@ import { useEffect } from 'react'; -import AppConstants from '../../../core/AppConstants'; import { Hex } from '@metamask/utils'; import { toChecksumHexAddress } from '@metamask/controller-utils'; -import usePrevious from '../../hooks/usePrevious'; -import { NETWORKS_CHAIN_ID } from '../../../constants/network'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import AppConstants from '../../../../../core/AppConstants'; +import { NETWORKS_CHAIN_ID } from '../../../../../constants/network'; +import usePrevious from '../../../../hooks/usePrevious'; // USDC and USDT for now const StablecoinsByChainId: Partial>> = { diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js index 9cf04eb49106..7c3501a77268 100644 --- a/app/components/UI/Swaps/QuotesView.js +++ b/app/components/UI/Swaps/QuotesView.js @@ -46,8 +46,8 @@ import { getErrorMessage, getFetchParams, getQuotesNavigationsParams, - isSwapsNativeAsset, } from './utils'; +import { isSwapsNativeAsset } from '../../../util/bridge'; import { strings } from '../../../../locales/i18n'; import Engine from '../../../core/Engine'; diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index 3c8813f91d4b..b79d63a09ea9 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -46,10 +46,10 @@ import AppConstants from '../../../core/AppConstants'; import { strings } from '../../../../locales/i18n'; import { setQuotesNavigationsParams, - isSwapsNativeAsset, isDynamicToken, shouldShowMaxBalanceLink, } from './utils'; +import { isSwapsNativeAsset } from '../../../util/bridge'; import { getSwapsAmountNavbar } from '../Navbar'; import useModalHandler from '../../Base/hooks/useModalHandler'; @@ -88,9 +88,9 @@ import { import { useMetrics } from '../../../components/hooks/useMetrics'; import { getSwapsLiveness } from '../../../reducers/swaps/utils'; import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; -import { useStablecoinsDefaultSlippage } from './useStablecoinsDefaultSlippage'; import { selectNetworkImageSourceByChainId } from '../../../selectors/networkInfos'; import ContextualNetworkPicker from '../ContextualNetworkPicker/ContextualNetworkPicker'; +import { useStablecoinsDefaultSlippage } from '../Bridge/hooks/useStablecoinsDefaultSlippage'; import Routes from '../../../constants/navigation/Routes'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { useChainRedirect } from './useChainRedirect'; diff --git a/app/components/UI/Swaps/utils/index.js b/app/components/UI/Swaps/utils/index.js index 8813f3968bef..3c7240db1be6 100644 --- a/app/components/UI/Swaps/utils/index.js +++ b/app/components/UI/Swaps/utils/index.js @@ -10,6 +10,7 @@ import { NATIVE_SWAPS_TOKEN_ADDRESS, SWAPS_TESTNET_CHAIN_ID, } from '../../../../constants/bridge'; +import { isSwapsNativeAsset } from '../../../../util/bridge'; const allowedChainIds = [ CHAIN_IDS.MAINNET, @@ -54,10 +55,6 @@ export function isSwapsAllowed(chainId) { return allowedChainIds.includes(chainId); } -export function isSwapsNativeAsset(token) { - return Boolean(token) && token?.address === NATIVE_SWAPS_TOKEN_ADDRESS; -} - export function isDynamicToken(token) { return ( Boolean(token) && diff --git a/app/components/UI/Swaps/utils/token-list-utils.ts b/app/components/UI/Swaps/utils/token-list-utils.ts index 92a7ab158e7e..5b073001d775 100644 --- a/app/components/UI/Swaps/utils/token-list-utils.ts +++ b/app/components/UI/Swaps/utils/token-list-utils.ts @@ -1,5 +1,4 @@ import { Hex } from '@metamask/utils'; -import { isSwapsNativeAsset } from '.'; import { safeToChecksumAddress } from '../../../../util/address'; import { balanceToFiatNumber, @@ -8,6 +7,7 @@ import { renderFromWei, weiToFiatNumber, } from '../../../../util/number'; +import { isSwapsNativeAsset } from '../../../../util/bridge'; export interface Token { address: string; diff --git a/app/components/UI/Swaps/utils/useBalance.js b/app/components/UI/Swaps/utils/useBalance.js index 956b8be19141..273f6e4e0c5b 100644 --- a/app/components/UI/Swaps/utils/useBalance.js +++ b/app/components/UI/Swaps/utils/useBalance.js @@ -1,11 +1,12 @@ import { useMemo } from 'react'; -import { isSwapsNativeAsset } from '.'; + import { renderFromTokenMinimalUnit, renderFromWei, safeNumberToBN, } from '../../../../util/number'; import { safeToChecksumAddress } from '../../../../util/address'; +import { isSwapsNativeAsset } from '../../../../util/bridge'; function useBalance( accounts, diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js index b1fcb28046e6..3f9b4740d19f 100644 --- a/app/components/UI/TransactionElement/utils.js +++ b/app/components/UI/TransactionElement/utils.js @@ -27,7 +27,7 @@ import { isTransactionIncomplete, } from '../../../util/transactions'; import { swapsUtils } from '@metamask/swaps-controller'; -import { isSwapsNativeAsset } from '../Swaps/utils'; +import { isSwapsNativeAsset } from '../../../util/bridge'; import Engine from '../../../core/Engine'; import { TransactionType } from '@metamask/transaction-controller'; import { diff --git a/app/util/bridge/index.test.ts b/app/util/bridge/index.test.ts new file mode 100644 index 000000000000..37f4af3aa334 --- /dev/null +++ b/app/util/bridge/index.test.ts @@ -0,0 +1,106 @@ +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../constants/bridge'; +import { isSwapsNativeAsset } from './index'; + +describe('isSwapsNativeAsset', () => { + describe('Native Token Detection', () => { + it('returns true for token with native address', () => { + const token = { address: NATIVE_SWAPS_TOKEN_ADDRESS }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(true); + }); + + it('returns false for token with non-native address', () => { + const token = { address: '0x1234567890123456789012345678901234567890' }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('returns false for undefined token', () => { + const result = isSwapsNativeAsset(undefined); + + expect(result).toBe(false); + }); + + it('returns false for null token', () => { + const result = isSwapsNativeAsset(null as unknown as undefined); + + expect(result).toBe(false); + }); + + it('returns false for token without address property', () => { + const token = {} as { address: string }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + + it('returns false for token with null address', () => { + const token = { address: null as unknown as string }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + + it('returns false for token with empty string address', () => { + const token = { address: '' }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + }); + + describe('Address Formatting', () => { + it('returns false for address with incorrect length', () => { + const token = { address: '0x00' }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + + it('returns false for address without 0x prefix', () => { + const token = { address: '0000000000000000000000000000000000000000' }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + }); + + describe('Token Object Variations', () => { + it('returns true when token has additional properties', () => { + const token = { + address: NATIVE_SWAPS_TOKEN_ADDRESS, + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(true); + }); + + it('returns false when token has wrong address but other properties', () => { + const token = { + address: '0x1111111111111111111111111111111111111111', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + }; + + const result = isSwapsNativeAsset(token); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/util/bridge/index.ts b/app/util/bridge/index.ts new file mode 100644 index 000000000000..521bf04145d0 --- /dev/null +++ b/app/util/bridge/index.ts @@ -0,0 +1,5 @@ +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../constants/bridge'; + +export function isSwapsNativeAsset(token: { address: string } | undefined) { + return Boolean(token) && token?.address === NATIVE_SWAPS_TOKEN_ADDRESS; +} From f6077624d6eb9aa535e272de2aaf6406a2abeea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Tue, 25 Nov 2025 18:33:33 +0000 Subject: [PATCH 3/9] chore: cp-7.60.0 bump tron snap and keyring-api package (#23196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We need to bump the `keyring-api` to version [21.2.0](https://github.com/MetaMask/accounts/compare/@metamask/keyring-api@21.1.0...@metamask/keyring-api@21.2.0) in order to have the latest types for `stake:deposit` and `stake:withdraw`. [Ref](https://github.com/MetaMask/accounts/pull/394) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/23251 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Updates dependencies to @metamask/keyring-api ^21.2.0 and @metamask/tron-wallet-snap ^1.12.1. > > - **Dependencies**: > - Upgrade `@metamask/keyring-api` to `^21.2.0`. > - Upgrade `@metamask/tron-wallet-snap` to `^1.12.1`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 616136ecfc357a2c69b0fb9ac0ff7f017c311c76. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Alejandro Garcia --- package.json | 4 ++-- yarn.lock | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 8d6868a8b575..d93573cf1b9c 100644 --- a/package.json +++ b/package.json @@ -233,7 +233,7 @@ "@metamask/json-rpc-engine": "^10.0.3", "@metamask/json-rpc-middleware-stream": "^8.0.7", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^21.1.0", + "@metamask/keyring-api": "^21.2.0", "@metamask/keyring-controller": "^24.0.0", "@metamask/keyring-internal-api": "^9.1.0", "@metamask/keyring-snap-client": "^8.1.0", @@ -288,7 +288,7 @@ "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", "@metamask/transaction-pay-controller": "^10.1.0", - "@metamask/tron-wallet-snap": "^1.10.0", + "@metamask/tron-wallet-snap": "^1.12.1", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", "@nktkas/hyperliquid": "^0.25.9", diff --git a/yarn.lock b/yarn.lock index 2cfdd0a5b63b..c89428f99e3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8391,15 +8391,15 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.1.0": - version: 21.1.0 - resolution: "@metamask/keyring-api@npm:21.1.0" +"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.1.0, @metamask/keyring-api@npm:^21.2.0": + version: 21.2.0 + resolution: "@metamask/keyring-api@npm:21.2.0" dependencies: "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.1.0" bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/3371a5ab0e9ba0e9b23b30b03a7d83d029e223def5485ab1aa2ec793ba18ff3738422dbe3c47f9cf82411d2ca6ca918928bf998741f0977055071b7bf3042314 + checksum: 10/cc3cd9f9ef65b33aa0af2f4aa556fab7ebab78ce21a09b8e1cb6f328b456c444e0169d7aac08dd62425fb12895d68dfee0ddb6e3d8a43950a8ba1852c6b92609 languageName: node linkType: hard @@ -9703,10 +9703,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:^1.10.0": - version: 1.10.0 - resolution: "@metamask/tron-wallet-snap@npm:1.10.0" - checksum: 10/c9b2f9cae0a2f9dcfd43934bd4321ed029e395122b61f1f3a8c3780dd4b44ac8669139b382da8b9230123639bf7a7554206223e3127d3e3317dfb802190cda46 +"@metamask/tron-wallet-snap@npm:^1.12.1": + version: 1.12.1 + resolution: "@metamask/tron-wallet-snap@npm:1.12.1" + checksum: 10/6f48c8dd6f625d7bb290bf3d39978839a0f4b905c14883e43fb35538b5ffa822f9611b8977fc54e9cb83711a95a9cbce93ad6a0149c4c31cfd1272af4b7055b0 languageName: node linkType: hard @@ -35678,7 +35678,7 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/json-rpc-middleware-stream": "npm:^8.0.7" "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-api": "npm:^21.1.0" + "@metamask/keyring-api": "npm:^21.2.0" "@metamask/keyring-controller": "npm:^24.0.0" "@metamask/keyring-internal-api": "npm:^9.1.0" "@metamask/keyring-snap-client": "npm:^8.1.0" @@ -35739,7 +35739,7 @@ __metadata: "@metamask/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" "@metamask/transaction-pay-controller": "npm:^10.1.0" - "@metamask/tron-wallet-snap": "npm:^1.10.0" + "@metamask/tron-wallet-snap": "npm:^1.12.1" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" "@nktkas/hyperliquid": "npm:^0.25.9" From e76aaf432b8607d0deebd961ccc4bdca79d533ab Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 25 Nov 2025 12:55:55 -0600 Subject: [PATCH 4/9] fix: cp-7.60.0 staked eth balances show first account staked balance across accounts (#23257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** 1. What is the reason for the change? There is a bug where we are not able to see the correct staked ethereum balance when there are multiple accounts with staked ethereum. There is also a bug where we are not able to see the APR and Earn History for a user with 0 staked ethereum balance. 2. What is the improvement/solution? - Ensure that the native asset is not set isStaked true, as the Staked Ethereum asset is the one which should be set isStaked true. - Add accountId to filtering of token data in assets list selector to ensure we have correct balances across multiple accounts for staked ethereum. - Allow zero value staked ethereum in the allTokens selector, but limit to supported staking chains to lessen amount of tokens returned. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-114 Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-112 Fixes: https://github.com/MetaMask/metamask-mobile/issues/23259 Fixes: https://github.com/MetaMask/metamask-mobile/issues/23260 ## **Manual testing steps** ```gherkin Feature: fix staked eth balances across multiple accounts and apr / earn history for zero balance staked eth Scenario: user has multiple accounts with ETH Given user stakes ETH on all accounts When user views the token list Then the Staked Ethereum balance should be correct in each account Then the Staked Ethereum asset detail page should show the correct amount for the account Then the Ethereum asset detail page should show the correct Staked Ethereum amount Scenario: user has multiple accounts with ETH, 1 with 0 Staked ETH Given user has 0 Staked Eth on an account When user views the token list Then the Staked Ethereum balance should not show in token list Then the Ethereum asset detail page should show no Staked Ethereum at all Then the Ethereum asset detail page should show APR and Earn History even when 0 Staked ETH ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b08a1c20-6e57-45cf-b238-53d3b5008b6a ### **After** https://github.com/user-attachments/assets/488c8b0e-57a8-4228-8e6f-0b43c00ee952 Simulator Screenshot - iPhone 17 Pro - 2025-11-25
at 10 15 28 Simulator Screenshot - iPhone 17 Pro - 2025-11-25
at 10 15 31 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Ensure native assets are never marked staked, include a distinct Staked Ethereum entry, and adjust zero-balance filtering to retain native/staked tokens on the current network; update tests accordingly. > > - **Selectors**: > - Set `isStaked: false` for native balances in `selectedAccountNativeTokenCachedBalanceByChainId*`. > - Update `selectNativeTokensAcrossChains` to always add a separate "Staked Ethereum" token alongside the native asset using cached staked balance data. > - Modify zero-balance behavior in `selectEvmTokensWithZeroBalanceFilter` to keep native and staked tokens on the current network when hiding zero balances. > - Cleanup: remove unused `toHex` import. > - **Tests**: > - Adjust expectations for `isStaked` on native balances and zero-balance filtering results. > - Assert presence of "Staked Ethereum" in filtered lists and updated token counts. > - Maintain memoization and network filtering coverage. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ce727563c1a373e97752e510539561ceaa4d5ef3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/selectors/multichain/evm.test.tsx | 8 +++++--- app/selectors/multichain/evm.ts | 16 +++------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/app/selectors/multichain/evm.test.tsx b/app/selectors/multichain/evm.test.tsx index 04e8bf7d7a7a..e8d3873099ed 100644 --- a/app/selectors/multichain/evm.test.tsx +++ b/app/selectors/multichain/evm.test.tsx @@ -156,7 +156,7 @@ describe('Multichain Selectors', () => { '0x1': { balance: '0x1', stakedBalance: '0x2', - isStaked: true, + isStaked: false, name: '', }, '0x89': { @@ -500,11 +500,13 @@ describe('Multichain Selectors', () => { } as unknown as RootState; const result = selectEvmTokensWithZeroBalanceFilter(testState); - expect(result).toBeDefined(); expect(result.every((token) => token.isNative === true)).toBeTruthy(); expect(result.every((token) => token.balance === '0')).toBeTruthy(); - expect(result.length).toBe(2); // Native tokens should remain + expect( + result.some((token) => token.name === 'Staked Ethereum'), + ).toBeTruthy(); + expect(result.length).toBe(3); // Native tokens should remain and Staked Ethereum }); }); diff --git a/app/selectors/multichain/evm.ts b/app/selectors/multichain/evm.ts index e6fc7ae2c869..98e6cf160889 100644 --- a/app/selectors/multichain/evm.ts +++ b/app/selectors/multichain/evm.ts @@ -18,11 +18,7 @@ import { } from '../networkController'; import { TokenI } from '../../components/UI/Tokens/types'; import { renderFromWei, weiToFiat } from '../../util/number'; -import { - hexToBN, - toChecksumHexAddress, - toHex, -} from '@metamask/controller-utils'; +import { hexToBN, toChecksumHexAddress } from '@metamask/controller-utils'; import { selectConversionRate, selectCurrencyRates, @@ -79,7 +75,7 @@ export const selectedAccountNativeTokenCachedBalanceByChainIdForAddress = result[chainId] = { balance: account.balance, stakedBalance: account.stakedBalance ?? '0x0', - isStaked: account.stakedBalance !== '0x0', + isStaked: false, name: '', }; } @@ -190,13 +186,7 @@ export const selectNativeTokensAcrossChainsForAddress = createSelector( // Non-staked tokens tokensByChain[nativeChainId].push(tokenByChain); - if ( - nativeTokenInfoByChainId && - nativeTokenInfoByChainId.isStaked && - nativeTokenInfoByChainId.stakedBalance !== '0x00' && - nativeTokenInfoByChainId.stakedBalance !== toHex(0) && - nativeTokenInfoByChainId.stakedBalance !== '0' - ) { + if (nativeTokenInfoByChainId && !nativeTokenInfoByChainId.isStaked) { // Staked tokens tokensByChain[nativeChainId].push({ ...nativeTokenInfoByChainId, From 0fcf5c979f4ac1f92c16dd01a801af4c3f9f0484 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:21:33 +0100 Subject: [PATCH 5/9] feat: [Trending] added support for refreshing + some code restructuring (#23250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Developed pull-to-refresh functionality on the min trending page plus a fe enhancements and code cleanup that was needed on some of the hooks. ## **Changelog** CHANGELOG entry: added pull-to-refresh for trending and some code cleanup ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1836 & https://consensyssoftware.atlassian.net/browse/ASSETS-1837 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/54b0d39c-e38b-4eef-83f8-3ab3e1d8c223 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds pull-to-refresh across Trending views and introduces a unified `useTrendingSearch` hook while refactoring requests to support refetching and remove debouncing. > > - **Trending UI** > - Add pull-to-refresh to `TrendingView` (main feed) and `TrendingTokensFullView` via `RefreshControl`. > - Propagate refresh via new `refreshTrigger` to `SectionCard` and `SectionCarrousel` which call each section’s `refetch`. > - **Sections Config** > - Tokens section now uses `useTrendingSearch`; sections expose `refetch` and accept `refreshTrigger`. > - Perps and Predictions sections pass through refresh and loading from their hooks. > - **New Hook**: `useTrendingSearch` > - Combines trending and search results, de-duplicates, applies sorting, and exposes `refetch` and loading states. > - **Requests Refactor** > - `useTrendingRequest` and `useSearchRequest`: remove debouncing, stabilize inputs, guard against stale responses, return plain arrays, and expose direct `fetch/search` functions. > - `useSitesData`: add `refetch` and memoized fetching; minor cleanup. > - **Misc** > - Update imports/paths for `usePopularNetworks`. > - Extensive test updates for new APIs, loading behavior, and race-condition handling. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8c77d6e583e350ebc05fcf7b4c65190a72a65cca. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: sahar-fehri --- .../TrendingTokenNetworkBottomSheet.test.tsx | 2 +- .../TrendingTokenNetworkBottomSheet.tsx | 2 +- .../usePopularNetworks.test.ts | 2 +- .../{index.ts => usePopularNetworks.ts} | 0 .../useSearchRequest/useSearchRequest.test.ts | 149 +++++----- .../{index.ts => useSearchRequest.ts} | 73 +---- .../useTrendingRequest.test.ts | 257 ++++++++---------- .../{index.ts => useTrendingRequest.ts} | 97 ++----- .../useTrendingSearch.test.ts | 171 ++++++++++++ .../useTrendingSearch/useTrendingSearch.ts | 62 +++++ .../TrendingTokensFullView.test.tsx | 43 +-- .../TrendingTokensFullView.tsx | 7 +- .../config/useExploreSearch.test.ts | 80 ++++-- .../SectionSites/hooks/useSitesData.ts | 85 +++--- .../Views/TrendingView/TrendingView.test.tsx | 19 +- .../Views/TrendingView/TrendingView.tsx | 32 ++- .../components/SectionCard/SectionCard.tsx | 16 +- .../SectionCarrousel/SectionCarrousel.tsx | 16 +- .../TrendingView/config/sections.config.tsx | 127 +++------ 19 files changed, 710 insertions(+), 530 deletions(-) rename app/components/UI/Trending/hooks/usePopularNetworks/{index.ts => usePopularNetworks.ts} (100%) rename app/components/UI/Trending/hooks/useSearchRequest/{index.ts => useSearchRequest.ts} (50%) rename app/components/UI/Trending/hooks/useTrendingRequest/{index.ts => useTrendingRequest.ts} (60%) create mode 100644 app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts create mode 100644 app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx index 7c3e6d69701f..11521dc4f0b0 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx @@ -38,7 +38,7 @@ const mockNetworks: ProcessedNetwork[] = [ const mockUsePopularNetworks = jest.fn(() => mockNetworks); -jest.mock('../../hooks/usePopularNetworks', () => ({ +jest.mock('../../hooks/usePopularNetworks/usePopularNetworks', () => ({ usePopularNetworks: () => mockUsePopularNetworks(), })); diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index d2cb9cece478..693f9e86b919 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -19,7 +19,7 @@ import Avatar, { import { strings } from '../../../../../../locales/i18n'; import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { CaipChainId } from '@metamask/utils'; -import { usePopularNetworks } from '../../hooks/usePopularNetworks'; +import { usePopularNetworks } from '../../hooks/usePopularNetworks/usePopularNetworks'; export enum NetworkOption { AllNetworks = 'all', diff --git a/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts index f609370f6454..079d0e9d3dae 100644 --- a/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts +++ b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { CaipChainId } from '@metamask/utils'; import { BtcScope, SolScope } from '@metamask/keyring-api'; import { isTestNet } from '../../../../../util/networks'; -import { usePopularNetworks } from '.'; +import { usePopularNetworks } from './usePopularNetworks'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), diff --git a/app/components/UI/Trending/hooks/usePopularNetworks/index.ts b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.ts similarity index 100% rename from app/components/UI/Trending/hooks/usePopularNetworks/index.ts rename to app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.ts diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts index 198999cb0d37..e480a112f640 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts @@ -1,6 +1,6 @@ -import { DEBOUNCE_WAIT, useSearchRequest } from '.'; +import { useSearchRequest } from './useSearchRequest'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { act } from '@testing-library/react-native'; +import { act, waitFor } from '@testing-library/react-native'; import { CaipChainId } from '@metamask/utils'; // eslint-disable-next-line import/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; @@ -8,7 +8,6 @@ import * as assetsControllers from '@metamask/assets-controllers'; describe('useSearchRequest', () => { beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); }); it('returns search results when search succeeds', async () => { @@ -31,11 +30,10 @@ describe('useSearchRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); + await waitFor(() => { + expect(spySearchTokens).toHaveBeenCalledTimes(1); }); - expect(spySearchTokens).toHaveBeenCalledTimes(1); expect(result.current.results).toEqual(mockResults); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); @@ -57,32 +55,44 @@ describe('useSearchRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); + await waitFor(() => { + expect(result.current.error).toEqual(mockError); }); - expect(result.current.error).toEqual(mockError); expect(result.current.results).toEqual([]); expect(result.current.isLoading).toBe(false); - // Ensure all operations complete before cleanup - await act(async () => { - result.current.search.cancel(); - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(DEBOUNCE_WAIT); - // Flush all remaining promises - for (let i = 0; i < 20; i++) { - await Promise.resolve(); - } - }); - spySearchTokens.mockRestore(); unmount(); }); - it('coalesces multiple rapid calls into a single search', async () => { + it('handles stale results when multiple requests are triggered', async () => { const spySearchTokens = jest.spyOn(assetsControllers, 'searchTokens'); - spySearchTokens.mockResolvedValue({ data: [] } as never); + const mockResults1 = [ + { + assetId: 'eip155:1/erc20:0x123', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + }, + ]; + const mockResults2 = [ + { + assetId: 'eip155:1/erc20:0x456', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + }, + ]; + + let resolveFirstRequest: ((value: unknown) => void) | undefined; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + + spySearchTokens + .mockReturnValueOnce(firstRequestPromise as never) + .mockResolvedValueOnce({ data: mockResults2 } as never); const { result, unmount } = renderHookWithProvider(() => useSearchRequest({ @@ -92,31 +102,27 @@ describe('useSearchRequest', () => { }), ); + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await result.current.search(); }); - spySearchTokens.mockClear(); + await waitFor(() => { + expect(result.current.results).toEqual(mockResults2); + }); await act(async () => { - result.current.search(); - result.current.search(); - result.current.search(); - - // Only test intermediate state if debounce wait is long enough - if (DEBOUNCE_WAIT > 100) { - jest.advanceTimersByTime(DEBOUNCE_WAIT - 100); - expect(spySearchTokens).not.toHaveBeenCalled(); - jest.advanceTimersByTime(200); - } else { - jest.advanceTimersByTime(DEBOUNCE_WAIT + 100); + if (resolveFirstRequest) { + resolveFirstRequest({ data: mockResults1 }); } - await Promise.resolve(); }); - expect(spySearchTokens).toHaveBeenCalledTimes(1); + expect(result.current.results).toEqual(mockResults2); + spySearchTokens.mockRestore(); unmount(); }); @@ -132,65 +138,72 @@ describe('useSearchRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); }); expect(spySearchTokens).not.toHaveBeenCalled(); expect(result.current.results).toEqual([]); - expect(result.current.isLoading).toBe(false); await act(async () => { await result.current.search(); - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); }); expect(spySearchTokens).not.toHaveBeenCalled(); + spySearchTokens.mockRestore(); unmount(); }); - it('maintains stable search function reference when chainIds array reference changes but values remain the same', async () => { + it('allows manual retry after error using search function', async () => { const spySearchTokens = jest.spyOn(assetsControllers, 'searchTokens'); - spySearchTokens.mockResolvedValue({ data: [] } as never); + const mockError = new Error('Failed to search tokens'); + const mockResults = [ + { + assetId: 'eip155:1/erc20:0x123', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + }, + ]; - let chainIds: CaipChainId[] = ['eip155:1', 'eip155:10']; - const { result, rerender, unmount } = renderHookWithProvider(() => + spySearchTokens.mockRejectedValueOnce(mockError); + + const { result, unmount } = renderHookWithProvider(() => useSearchRequest({ - chainIds, + chainIds: ['eip155:1'], query: 'ETH', limit: 10, }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.error).toEqual(mockError); }); - const firstSearchFunction = result.current.search; - - chainIds = ['eip155:1', 'eip155:10']; - rerender(undefined); + spySearchTokens.mockResolvedValue({ data: mockResults } as never); await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await result.current.search(); + }); + + await waitFor(() => { + expect(result.current.error).toBe(null); }); - expect(result.current.search).toBe(firstSearchFunction); + expect(result.current.results).toEqual(mockResults); + expect(result.current.isLoading).toBe(false); + spySearchTokens.mockRestore(); unmount(); }); - it('creates new search function when chainIds values change', async () => { + it('triggers new search when chainIds values change', async () => { const spySearchTokens = jest.spyOn(assetsControllers, 'searchTokens'); spySearchTokens.mockResolvedValue({ data: [] } as never); let chainIds: CaipChainId[] = ['eip155:1', 'eip155:10']; - const { result, rerender, unmount } = renderHookWithProvider(() => + const { rerender, unmount } = renderHookWithProvider(() => useSearchRequest({ chainIds, query: 'ETH', @@ -198,22 +211,18 @@ describe('useSearchRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(spySearchTokens).toHaveBeenCalledTimes(1); }); - const firstSearchFunction = result.current.search; - chainIds = ['eip155:1', 'eip155:137']; rerender(undefined); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(spySearchTokens).toHaveBeenCalledTimes(2); }); - expect(result.current.search).not.toBe(firstSearchFunction); + spySearchTokens.mockRestore(); unmount(); }); }); diff --git a/app/components/UI/Trending/hooks/useSearchRequest/index.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts similarity index 50% rename from app/components/UI/Trending/hooks/useSearchRequest/index.ts rename to app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts index 3b1e0217fa51..56bdc3845b5b 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/index.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts @@ -1,9 +1,7 @@ -import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; -import { debounce } from 'lodash'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { CaipChainId } from '@metamask/utils'; import { searchTokens } from '@metamask/assets-controllers'; import { useStableArray } from '../../../Perps/hooks/useStableArray'; -export const DEBOUNCE_WAIT = 0; /** * Hook for handling search tokens request @@ -15,33 +13,19 @@ export const useSearchRequest = (options: { limit: number; }) => { const { chainIds, query, limit } = options; - const [results, setResults] = useState - > | null>(null); + const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Track the current request ID to prevent stale results from overwriting current ones const requestIdRef = useRef(0); - // Stabilize the chainIds array reference to prevent unnecessary re-memoization + // Stabilize the chainIds array reference to prevent unnecessary re-fetching const stableChainIds = useStableArray(chainIds); - // Memoize the options object to ensure stable reference - const memoizedOptions = useMemo( - () => ({ - chainIds: stableChainIds, - query, - limit, - }), - [stableChainIds, query, limit], - ); - const searchTokensRequest = useCallback(async () => { - if (!memoizedOptions.query) { - // Increment request ID to invalidate any pending requests - ++requestIdRef.current; - setResults(null); + if (!query) { + setResults([]); setIsLoading(false); return; } @@ -52,22 +36,18 @@ export const useSearchRequest = (options: { setError(null); try { - const searchResults = await searchTokens( - memoizedOptions.chainIds, - memoizedOptions.query, - { - limit: memoizedOptions.limit, - }, - ); + const searchResults = await searchTokens(stableChainIds, query, { + limit, + }); // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { - setResults(searchResults || null); + setResults(searchResults?.data || []); } } catch (err) { // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { setError(err as Error); - setResults(null); + setResults([]); } } finally { // Only update loading state if this is still the current request @@ -75,40 +55,17 @@ export const useSearchRequest = (options: { setIsLoading(false); } } - }, [memoizedOptions]); - - const debouncedSearchTokensRequest = useMemo( - () => debounce(searchTokensRequest, DEBOUNCE_WAIT), - [searchTokensRequest], - ); + }, [stableChainIds, query, limit]); // Automatically trigger search when query changes - // Cancel previous debounced function BEFORE triggering new one to prevent race conditions useEffect(() => { - // Cancel any pending debounced calls from previous render - debouncedSearchTokensRequest.cancel(); - - setIsLoading(true); - - // If query is empty, don't trigger search - if (!memoizedOptions.query) { - setIsLoading(false); - return; - } - - // Trigger new search - debouncedSearchTokensRequest(); - - // Cleanup: cancel on unmount or when dependencies change - return () => { - debouncedSearchTokensRequest.cancel(); - }; - }, [debouncedSearchTokensRequest, memoizedOptions.query]); + searchTokensRequest(); + }, [searchTokensRequest]); return { - results: results?.data || [], + results, isLoading, error, - search: debouncedSearchTokensRequest, + search: searchTokensRequest, }; }; diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts index 1a0974a43aa9..1cc7fd9dc011 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts @@ -1,6 +1,6 @@ -import { DEBOUNCE_WAIT, useTrendingRequest } from '.'; +import { useTrendingRequest } from './useTrendingRequest'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { act } from '@testing-library/react-native'; +import { act, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; import { @@ -52,7 +52,6 @@ const mockDefaultNetworks: ProcessedNetwork[] = [ describe('useTrendingRequest', () => { beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); // Set up default mocks for network hooks mockUseNetworksByNamespace.mockReturnValue({ networks: mockDefaultNetworks, @@ -75,35 +74,6 @@ describe('useTrendingRequest', () => { } as unknown as ReturnType); }); - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns an object with results, isLoading, error, and fetch function', () => { - const spyGetTrendingTokens = jest.spyOn( - assetsControllers, - 'getTrendingTokens', - ); - spyGetTrendingTokens.mockResolvedValue([]); - - const { result, unmount } = renderHookWithProvider(() => - useTrendingRequest({ - chainIds: ['eip155:1'], - }), - ); - - expect(result.current).toHaveProperty('results'); - expect(result.current).toHaveProperty('isLoading'); - expect(result.current).toHaveProperty('error'); - expect(result.current).toHaveProperty('fetch'); - expect(typeof result.current.fetch).toBe('function'); - expect(Array.isArray(result.current.results)).toBe(true); - expect(typeof result.current.isLoading).toBe('boolean'); - - spyGetTrendingTokens.mockRestore(); - unmount(); - }); - it('returns trending tokens results when fetch succeeds', async () => { const spyGetTrendingTokens = jest.spyOn( assetsControllers, @@ -137,12 +107,10 @@ describe('useTrendingRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(spyGetTrendingTokens).toHaveBeenCalledTimes(1); }); - expect(spyGetTrendingTokens).toHaveBeenCalledTimes(1); expect(result.current.results).toEqual(mockResults); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); @@ -168,21 +136,19 @@ describe('useTrendingRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.isLoading).toBe(true); }); - expect(result.current.isLoading).toBe(true); - await act(async () => { if (resolvePromise) { resolvePromise([]); } - await Promise.resolve(); }); - expect(result.current.isLoading).toBe(false); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); spyGetTrendingTokens.mockRestore(); unmount(); @@ -202,12 +168,10 @@ describe('useTrendingRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.error).toEqual(mockError); }); - expect(result.current.error).toEqual(mockError); expect(result.current.results).toEqual([]); expect(result.current.isLoading).toBe(false); @@ -241,70 +205,20 @@ describe('useTrendingRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.error).toEqual(mockError); }); - expect(result.current.error).toEqual(mockError); - spyGetTrendingTokens.mockResolvedValue(mockResults as never); await act(async () => { - result.current.fetch(); - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await result.current.fetch(); }); - expect(result.current.error).toBe(null); - expect(result.current.results).toEqual(mockResults); - expect(result.current.isLoading).toBe(false); - - spyGetTrendingTokens.mockRestore(); - unmount(); - }); - - it('uses default popular networks when chainIds is empty', async () => { - const spyGetTrendingTokens = jest.spyOn( - assetsControllers, - 'getTrendingTokens', - ); - const mockResults: assetsControllers.TrendingAsset[] = [ - { - assetId: 'eip155:1/erc20:0x123', - symbol: 'TOKEN1', - name: 'Token 1', - decimals: 18, - price: '1', - aggregatedUsdVolume: 1, - marketCap: 1, - }, - ]; - spyGetTrendingTokens.mockResolvedValue(mockResults as never); - - const { result, unmount } = renderHookWithProvider(() => - useTrendingRequest({ - chainIds: [], - }), - ); - - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.error).toBe(null); }); - expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({ - networkType: 'popular', - }); - expect(mockUseNetworksToUse).toHaveBeenCalledWith({ - networks: mockDefaultNetworks, - networkType: 'popular', - }); - expect(spyGetTrendingTokens).toHaveBeenCalledWith( - expect.objectContaining({ - chainIds: ['eip155:1', 'eip155:137'], - }), - ); expect(result.current.results).toEqual(mockResults); expect(result.current.isLoading).toBe(false); @@ -312,32 +226,55 @@ describe('useTrendingRequest', () => { unmount(); }); - it('uses default popular networks when chainIds is not provided', async () => { - const spyGetTrendingTokens = jest.spyOn( - assetsControllers, - 'getTrendingTokens', - ); - const mockResults: assetsControllers.TrendingAsset[] = []; - spyGetTrendingTokens.mockResolvedValue(mockResults as never); - - renderHookWithProvider(() => useTrendingRequest({})); - - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); - }); - - expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({ - networkType: 'popular', - }); - expect(spyGetTrendingTokens).toHaveBeenCalledWith( - expect.objectContaining({ - chainIds: ['eip155:1', 'eip155:137'], - }), - ); - - spyGetTrendingTokens.mockRestore(); - }); + it.each([ + { description: 'empty array', options: { chainIds: [] } }, + { description: 'not provided', options: {} }, + ])( + 'uses default popular networks when chainIds is $description', + async ({ options }) => { + const spyGetTrendingTokens = jest.spyOn( + assetsControllers, + 'getTrendingTokens', + ); + const mockResults: assetsControllers.TrendingAsset[] = [ + { + assetId: 'eip155:1/erc20:0x123', + symbol: 'TOKEN1', + name: 'Token 1', + decimals: 18, + price: '1', + aggregatedUsdVolume: 1, + marketCap: 1, + }, + ]; + spyGetTrendingTokens.mockResolvedValue(mockResults as never); + + const { result } = renderHookWithProvider(() => + useTrendingRequest(options), + ); + + await waitFor(() => { + expect(spyGetTrendingTokens).toHaveBeenCalledTimes(1); + }); + + expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({ + networkType: 'popular', + }); + expect(mockUseNetworksToUse).toHaveBeenCalledWith({ + networks: mockDefaultNetworks, + networkType: 'popular', + }); + expect(spyGetTrendingTokens).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: ['eip155:1', 'eip155:137'], + }), + ); + expect(result.current.results).toEqual(mockResults); + expect(result.current.isLoading).toBe(false); + + spyGetTrendingTokens.mockRestore(); + }, + ); it('uses provided chainIds when available instead of default networks', async () => { const spyGetTrendingTokens = jest.spyOn( @@ -357,9 +294,8 @@ describe('useTrendingRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(spyGetTrendingTokens).toHaveBeenCalledTimes(1); }); expect(spyGetTrendingTokens).toHaveBeenCalledWith( @@ -371,12 +307,42 @@ describe('useTrendingRequest', () => { spyGetTrendingTokens.mockRestore(); }); - it('coalesces multiple rapid calls into a single fetch', async () => { + it('handles stale results when multiple requests are triggered', async () => { const spyGetTrendingTokens = jest.spyOn( assetsControllers, 'getTrendingTokens', ); - spyGetTrendingTokens.mockResolvedValue([]); + const mockResults1: assetsControllers.TrendingAsset[] = [ + { + assetId: 'eip155:1/erc20:0x123', + symbol: 'TOKEN1', + name: 'Token 1', + decimals: 18, + price: '1', + aggregatedUsdVolume: 1, + marketCap: 1, + }, + ]; + const mockResults2: assetsControllers.TrendingAsset[] = [ + { + assetId: 'eip155:1/erc20:0x456', + symbol: 'TOKEN2', + name: 'Token 2', + decimals: 18, + price: '2', + aggregatedUsdVolume: 2, + marketCap: 2, + }, + ]; + + let resolveFirstRequest: ((value: unknown[]) => void) | undefined; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + + spyGetTrendingTokens + .mockReturnValueOnce(firstRequestPromise as never) + .mockResolvedValueOnce(mockResults2 as never); const { result, unmount } = renderHookWithProvider(() => useTrendingRequest({ @@ -384,26 +350,25 @@ describe('useTrendingRequest', () => { }), ); - await act(async () => { - jest.advanceTimersByTime(DEBOUNCE_WAIT); - await Promise.resolve(); + await waitFor(() => { + expect(result.current.isLoading).toBe(true); }); - spyGetTrendingTokens.mockClear(); - await act(async () => { - result.current.fetch(); - result.current.fetch(); - result.current.fetch(); + await result.current.fetch(); + }); - jest.advanceTimersByTime(DEBOUNCE_WAIT - 100); - expect(spyGetTrendingTokens).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.results).toEqual(mockResults2); + }); - jest.advanceTimersByTime(DEBOUNCE_WAIT + 200); - await Promise.resolve(); + await act(async () => { + if (resolveFirstRequest) { + resolveFirstRequest(mockResults1); + } }); - expect(spyGetTrendingTokens).toHaveBeenCalledTimes(1); + expect(result.current.results).toEqual(mockResults2); spyGetTrendingTokens.mockRestore(); unmount(); diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/index.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts similarity index 60% rename from app/components/UI/Trending/hooks/useTrendingRequest/index.ts rename to app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts index da61d8ed85a4..4e720cfb3be6 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/index.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts @@ -1,5 +1,4 @@ import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; -import { debounce } from 'lodash'; import type { CaipChainId } from '@metamask/utils'; import { getTrendingTokens, @@ -13,8 +12,6 @@ import { } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse'; -export const DEBOUNCE_WAIT = 500; - /** * Hook for handling trending tokens request * @returns {Object} An object containing the trending tokens results, loading state, error, and a function to trigger fetch @@ -61,43 +58,20 @@ export const useTrendingRequest = (options: { // Track the current request ID to prevent stale results from overwriting current ones const requestIdRef = useRef(0); - // Stabilize the chainIds array reference to prevent unnecessary re-memoization + // Stabilize the chainIds array reference to prevent unnecessary re-fetching const stableChainIds = useStableArray(chainIds); - // Memoize the options object to ensure stable reference - const memoizedOptions = useMemo( - () => ({ - chainIds: stableChainIds, - sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - }), - [ - stableChainIds, - sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - ], - ); - - const [results, setResults] = useState - > | null>(null); + const [results, setResults] = useState< + Awaited> + >([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const fetchTrendingTokens = useCallback(async () => { - if (!memoizedOptions.chainIds.length) { - ++requestIdRef.current; - setResults(null); + if (!stableChainIds.length) { + setResults([]); setIsLoading(false); return; } @@ -109,13 +83,13 @@ export const useTrendingRequest = (options: { try { const resultsToStore = await getTrendingTokens({ - chainIds: memoizedOptions.chainIds, - sortBy: memoizedOptions.sortBy, - minLiquidity: memoizedOptions.minLiquidity, - minVolume24hUsd: memoizedOptions.minVolume24hUsd, - maxVolume24hUsd: memoizedOptions.maxVolume24hUsd, - minMarketCap: memoizedOptions.minMarketCap, - maxMarketCap: memoizedOptions.maxMarketCap, + chainIds: stableChainIds, + sortBy, + minLiquidity, + minVolume24hUsd, + maxVolume24hUsd, + minMarketCap, + maxMarketCap, }); // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { @@ -125,7 +99,7 @@ export const useTrendingRequest = (options: { // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { setError(err as Error); - setResults(null); + setResults([]); } } finally { // Only update loading state if this is still the current request @@ -133,42 +107,25 @@ export const useTrendingRequest = (options: { setIsLoading(false); } } - }, [memoizedOptions]); - - const debouncedFetchTrendingTokens = useMemo( - () => debounce(fetchTrendingTokens, DEBOUNCE_WAIT), - [fetchTrendingTokens], - ); + }, [ + stableChainIds, + sortBy, + minLiquidity, + minVolume24hUsd, + maxVolume24hUsd, + minMarketCap, + maxMarketCap, + ]); // Automatically trigger fetch when options change - // Cancel previous debounced function BEFORE triggering new one to prevent race conditions useEffect(() => { - // Cancel any pending debounced calls from previous render - debouncedFetchTrendingTokens.cancel(); - - // If chainIds is empty, don't trigger fetch - if (!stableChainIds.length) { - setResults(null); - setIsLoading(false); - return; - } - - // Immediately show loading state so UI can render skeleton right away - setIsLoading(true); - - // Fetch new data - debouncedFetchTrendingTokens(); - - // Cleanup: cancel on unmount or when dependencies change - return () => { - debouncedFetchTrendingTokens.cancel(); - }; - }, [debouncedFetchTrendingTokens, stableChainIds, memoizedOptions]); + fetchTrendingTokens(); + }, [fetchTrendingTokens]); return { - results: results || [], + results, isLoading, error, - fetch: debouncedFetchTrendingTokens, + fetch: fetchTrendingTokens, }; }; diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts new file mode 100644 index 000000000000..8cb4b31f938a --- /dev/null +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts @@ -0,0 +1,171 @@ +import { useTrendingSearch } from './useTrendingSearch'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { waitFor } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useSearchRequest } from '../useSearchRequest/useSearchRequest'; +import { useTrendingRequest } from '../useTrendingRequest/useTrendingRequest'; +import { sortTrendingTokens } from '../../utils/sortTrendingTokens'; + +// Mock dependencies +jest.mock('../useSearchRequest/useSearchRequest'); +jest.mock('../useTrendingRequest/useTrendingRequest'); +jest.mock('../../utils/sortTrendingTokens'); + +const mockUseSearchRequest = useSearchRequest as jest.MockedFunction< + typeof useSearchRequest +>; +const mockUseTrendingRequest = useTrendingRequest as jest.MockedFunction< + typeof useTrendingRequest +>; +const mockSortTrendingTokens = sortTrendingTokens as jest.MockedFunction< + typeof sortTrendingTokens +>; + +describe('useTrendingSearch', () => { + const mockTrendingResults: TrendingAsset[] = [ + { + assetId: 'eip155:1/erc20:0x123', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + price: '2000', + aggregatedUsdVolume: 1000000, + marketCap: 500000000, + }, + { + assetId: 'eip155:1/erc20:0x456', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + price: '1', + aggregatedUsdVolume: 500000, + marketCap: 100000000, + }, + ]; + + const mockSearchResults = [ + { + assetId: 'eip155:1/erc20:0x789', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + price: '1', + aggregatedUsdVolume: 800000, + marketCap: 300000000, + }, + ]; + + const mockFetchTrendingTokens = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + mockUseSearchRequest.mockReturnValue({ + results: [], + isLoading: false, + error: null, + search: jest.fn(), + }); + + mockUseTrendingRequest.mockReturnValue({ + results: mockTrendingResults, + isLoading: false, + error: null, + fetch: mockFetchTrendingTokens, + }); + + mockSortTrendingTokens.mockImplementation((tokens) => tokens); + }); + + it('returns sorted trending results when no search query provided', async () => { + const sortedResults = [mockTrendingResults[1], mockTrendingResults[0]]; + mockSortTrendingTokens.mockReturnValue(sortedResults); + + const { result } = renderHookWithProvider(() => useTrendingSearch()); + + await waitFor(() => { + expect(result.current.data).toEqual(sortedResults); + }); + + expect(mockSortTrendingTokens).toHaveBeenCalledWith( + mockTrendingResults, + expect.any(String), + ); + expect(result.current.isLoading).toBe(false); + }); + + it('returns combined search and trending results when search query provided', async () => { + mockUseSearchRequest.mockReturnValue({ + results: mockSearchResults, + isLoading: false, + error: null, + search: jest.fn(), + }); + + const { result } = renderHookWithProvider(() => + useTrendingSearch('USDC', 'h24_trending'), + ); + + await waitFor(() => { + expect(result.current.data).toHaveLength(3); + }); + + expect(result.current.data).toEqual( + expect.arrayContaining([ + ...mockTrendingResults, + expect.objectContaining({ symbol: 'USDC' }), + ]), + ); + }); + + it('removes duplicate results when combining search and trending', async () => { + const duplicateResult = mockTrendingResults[0]; + mockUseSearchRequest.mockReturnValue({ + results: [duplicateResult, mockSearchResults[0]], + isLoading: false, + error: null, + search: jest.fn(), + }); + + const { result } = renderHookWithProvider(() => + useTrendingSearch('ETH', 'h24_trending'), + ); + + await waitFor(() => { + expect(result.current.data).toHaveLength(3); + }); + + const assetIds = result.current.data.map((item) => item.assetId); + const uniqueAssetIds = new Set(assetIds); + expect(assetIds.length).toBe(uniqueAssetIds.size); + }); + + it('returns trending loading state when no search query', () => { + mockUseTrendingRequest.mockReturnValue({ + results: [], + isLoading: true, + error: null, + fetch: mockFetchTrendingTokens, + }); + + const { result } = renderHookWithProvider(() => useTrendingSearch()); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns search loading state when search query provided', () => { + mockUseSearchRequest.mockReturnValue({ + results: [], + isLoading: true, + error: null, + search: jest.fn(), + }); + + const { result } = renderHookWithProvider(() => + useTrendingSearch('ETH', 'h24_trending'), + ); + + expect(result.current.isLoading).toBe(true); + }); +}); diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts new file mode 100644 index 000000000000..e2a9ed53d5cd --- /dev/null +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import type { CaipChainId } from '@metamask/utils'; +import { SortTrendingBy, TrendingAsset } from '@metamask/assets-controllers'; +import { useSearchRequest } from '../useSearchRequest/useSearchRequest'; +import { useTrendingRequest } from '../useTrendingRequest/useTrendingRequest'; +import { sortTrendingTokens } from '../../utils/sortTrendingTokens'; +import { PriceChangeOption } from '../../components/TrendingTokensBottomSheet'; + +/** + * Hook for handling trending tokens search that returns trending tokens and tokens from search API + * @returns {Object} An object containing the trending tokens results, token search results, loading state, error, and a function to trigger fetch + */ +export const useTrendingSearch = ( + searchQuery?: string, + sortBy?: SortTrendingBy, + chainIds?: CaipChainId[] | null, +) => { + // Trending will return tokens that have just been created which wont be picked up by search API + // so if you see a token on trending and search on omnisearch which uses the search endpoint... + // There is a chance you will get 0 results + const { results: searchResults, isLoading: isSearchLoading } = + useSearchRequest({ + query: searchQuery || '', + limit: 20, + chainIds: [], + }); + + const { + results: trendingResults, + isLoading: isTrendingLoading, + fetch: fetchTrendingTokens, + } = useTrendingRequest({ + sortBy, + chainIds: chainIds ?? undefined, + }); + + const data = useMemo(() => { + if (!searchQuery) { + return sortTrendingTokens(trendingResults, PriceChangeOption.PriceChange); + } + + // Combine trending and search results, avoiding duplicates + const resultMap = new Map( + trendingResults.map((result) => [result.assetId, result]), + ); + + searchResults.forEach((result) => { + const asset = result as TrendingAsset; + if (!resultMap.has(asset.assetId)) { + resultMap.set(asset.assetId, asset); + } + }); + + return Array.from(resultMap.values()); + }, [searchQuery, trendingResults, searchResults]); + + return { + data, + isLoading: searchQuery ? isSearchLoading : isTrendingLoading, + refetch: fetchTrendingTokens, + }; +}; diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index 008b80dec839..24757f1c29df 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -22,19 +22,30 @@ const mockUseTrendingRequest = jest.fn().mockReturnValue({ error: null, fetch: mockFetchTrendingTokens, }); -jest.mock('../../../UI/Trending/hooks/useTrendingRequest', () => ({ - useTrendingRequest: (options: unknown) => mockUseTrendingRequest(options), -})); +jest.mock( + '../../../UI/Trending/hooks/useTrendingRequest/useTrendingRequest', + () => ({ + useTrendingRequest: (options: unknown) => mockUseTrendingRequest(options), + }), +); -const mockUseSectionData = jest.fn(); +const mockUseTrendingSearch = jest.fn(); + +jest.mock( + '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', + () => ({ + useTrendingSearch: ( + searchQuery?: string, + sortBy?: unknown, + chainIds?: unknown, + ) => mockUseTrendingSearch({ searchQuery, sortBy, chainIds }), + }), +); // Mock sections.config to avoid complex Perps dependencies -// Make useSectionData return the same data as useTrendingRequest jest.mock('../../TrendingView/config/sections.config', () => ({ SECTIONS_CONFIG: { tokens: { - useSectionData: (params?: { searchQuery?: string }) => - mockUseSectionData(params), getSearchableText: (item: { name?: string; symbol?: string }) => `${item.name || ''} ${item.symbol || ''}`.toLowerCase(), }, @@ -216,7 +227,7 @@ describe('TrendingTokensFullView', () => { error: null, fetch: jest.fn(), }); - mockUseSectionData.mockReturnValue({ + mockUseTrendingSearch.mockReturnValue({ data: [], isLoading: false, refetch: jest.fn(), @@ -352,7 +363,7 @@ describe('TrendingTokensFullView', () => { fetch: jest.fn(), }); - mockUseSectionData.mockReturnValue({ + mockUseTrendingSearch.mockReturnValue({ data: mockTokens, isLoading: false, refetch: jest.fn(), @@ -369,10 +380,10 @@ describe('TrendingTokensFullView', () => { expect(getByText('Token 2')).toBeOnTheScreen(); }); - it('calls useSectionData with correct initial parameters', () => { + it('calls useTrendingSearch with correct initial parameters', () => { renderWithProvider(, { state: mockState }, false); - expect(mockUseSectionData).toHaveBeenCalledWith({ + expect(mockUseTrendingSearch).toHaveBeenCalledWith({ sortBy: undefined, chainIds: null, searchQuery: undefined, @@ -395,7 +406,7 @@ describe('TrendingTokensFullView', () => { }); await waitFor(() => { - expect(mockUseSectionData).toHaveBeenLastCalledWith({ + expect(mockUseTrendingSearch).toHaveBeenLastCalledWith({ sortBy: 'h6_trending', chainIds: null, searchQuery: undefined, @@ -419,7 +430,7 @@ describe('TrendingTokensFullView', () => { }); await waitFor(() => { - expect(mockUseSectionData).toHaveBeenLastCalledWith({ + expect(mockUseTrendingSearch).toHaveBeenLastCalledWith({ sortBy: undefined, chainIds: ['eip155:1'], searchQuery: undefined, @@ -440,7 +451,7 @@ describe('TrendingTokensFullView', () => { fetch: jest.fn(), }); - mockUseSectionData.mockReturnValue({ + mockUseTrendingSearch.mockReturnValue({ data: mockTokens, isLoading: false, refetch: jest.fn(), @@ -475,14 +486,14 @@ describe('TrendingTokensFullView', () => { }), ]; - mockUseTrendingRequest.mockReturnValueOnce({ + mockUseTrendingRequest.mockReturnValue({ results: mockTokens, isLoading: false, error: null, fetch: mockFetchTrendingTokens, }); - mockUseSectionData.mockReturnValue({ + mockUseTrendingSearch.mockReturnValue({ data: mockTokens, isLoading: false, refetch: mockFetchTrendingTokens, diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index 81a520162845..2ea31d167afb 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -41,6 +41,7 @@ import { } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; import { SECTIONS_CONFIG } from '../../TrendingView/config/sections.config'; +import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; interface TrendingTokensNavigationParamList { [key: string]: undefined | object; @@ -201,11 +202,7 @@ const TrendingTokensFullView = () => { data: tokensSectionData, isLoading, refetch: refetchTokensSection, - } = SECTIONS_CONFIG.tokens.useSectionData({ - searchQuery: searchQuery || undefined, - sortBy, - chainIds: selectedNetwork, - }); + } = useTrendingSearch(searchQuery || undefined, sortBy, selectedNetwork); const searchResults = useMemo(() => { // When search is not active, use the full section data diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts index 7aba9bc80de8..7afd60bad5b0 100644 --- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts +++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts @@ -24,18 +24,39 @@ const mockPredictionMarkets = [ { id: '4', title: 'Trump election results' }, ]; -const mockUseTrendingRequest = jest.fn(); -const mockUseSearchRequest = jest.fn(); +const mockSites = [ + { + id: '1', + name: 'Uniswap', + url: 'https://uniswap.org', + displayUrl: 'uniswap.org', + }, + { + id: '2', + name: 'OpenSea', + url: 'https://opensea.io', + displayUrl: 'opensea.io', + }, + { id: '3', name: 'Aave', url: 'https://aave.com', displayUrl: 'aave.com' }, + { + id: '4', + name: 'Compound', + url: 'https://compound.finance', + displayUrl: 'compound.finance', + }, +]; + +const mockUseTrendingSearch = jest.fn(); const mockUsePerpsMarkets = jest.fn(); const mockUsePredictMarketData = jest.fn(); +const mockUseSitesData = jest.fn(); -jest.mock('../../../../../../UI/Trending/hooks/useTrendingRequest', () => ({ - useTrendingRequest: () => mockUseTrendingRequest(), -})); - -jest.mock('../../../../../../UI/Trending/hooks/useSearchRequest', () => ({ - useSearchRequest: () => mockUseSearchRequest(), -})); +jest.mock( + '../../../../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', + () => ({ + useTrendingSearch: () => mockUseTrendingSearch(), + }), +); jest.mock('../../../../../../UI/Perps/hooks/usePerpsMarkets', () => ({ usePerpsMarkets: () => mockUsePerpsMarkets(), @@ -45,29 +66,38 @@ jest.mock('../../../../../../UI/Predict/hooks/usePredictMarketData', () => ({ usePredictMarketData: () => mockUsePredictMarketData(), })); +jest.mock('../../../../SectionSites/hooks/useSitesData', () => ({ + useSitesData: () => mockUseSitesData(), +})); + describe('useExploreSearch', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - mockUseTrendingRequest.mockReturnValue({ - results: mockTrendingTokens, - isLoading: false, - }); - - mockUseSearchRequest.mockReturnValue({ - results: mockTrendingTokens, + mockUseTrendingSearch.mockReturnValue({ + data: mockTrendingTokens, isLoading: false, + refetch: jest.fn(), }); mockUsePerpsMarkets.mockReturnValue({ markets: mockPerpsMarkets, isLoading: false, + refresh: jest.fn(), + isRefreshing: false, }); mockUsePredictMarketData.mockReturnValue({ marketData: mockPredictionMarkets, isFetching: false, + refetch: jest.fn(), + }); + + mockUseSitesData.mockReturnValue({ + sites: mockSites, + isLoading: false, + refetch: jest.fn(), }); }); @@ -82,6 +112,7 @@ describe('useExploreSearch', () => { expect(result.current.data.tokens).toHaveLength(3); expect(result.current.data.perps).toHaveLength(3); expect(result.current.data.predictions).toHaveLength(3); + expect(result.current.data.sites).toHaveLength(3); }); it('returns top 3 items when query contains only whitespace', () => { @@ -90,6 +121,7 @@ describe('useExploreSearch', () => { expect(result.current.data.tokens).toHaveLength(3); expect(result.current.data.perps).toHaveLength(3); expect(result.current.data.predictions).toHaveLength(3); + expect(result.current.data.sites).toHaveLength(3); }); it('filters tokens by symbol when query matches', async () => { @@ -208,6 +240,7 @@ describe('useExploreSearch', () => { expect(result.current.data.tokens).toHaveLength(0); expect(result.current.data.perps).toHaveLength(0); expect(result.current.data.predictions).toHaveLength(0); + expect(result.current.data.sites).toHaveLength(0); }); }); @@ -237,19 +270,29 @@ describe('useExploreSearch', () => { }); it('returns loading states for each section', () => { - mockUseTrendingRequest.mockReturnValue({ - results: [], + mockUseTrendingSearch.mockReturnValue({ + data: [], isLoading: true, + refetch: jest.fn(), }); mockUsePerpsMarkets.mockReturnValue({ markets: [], isLoading: true, + refresh: jest.fn(), + isRefreshing: false, }); mockUsePredictMarketData.mockReturnValue({ marketData: [], isFetching: true, + refetch: jest.fn(), + }); + + mockUseSitesData.mockReturnValue({ + sites: [], + isLoading: true, + refetch: jest.fn(), }); const { result } = renderHook(() => useExploreSearch('')); @@ -257,6 +300,7 @@ describe('useExploreSearch', () => { expect(result.current.isLoading.tokens).toBe(true); expect(result.current.isLoading.perps).toBe(true); expect(result.current.isLoading.predictions).toBe(true); + expect(result.current.isLoading.sites).toBe(true); }); it('filters across multiple sections simultaneously', async () => { diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts b/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts index df7ae454c8ce..21b042710f54 100644 --- a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts +++ b/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import Logger from '../../../../../util/Logger'; import type { SiteData } from '../SiteRowItem/SiteRowItem'; @@ -29,6 +29,7 @@ interface UseSitesDataResult { sites: SiteData[]; isLoading: boolean; error: Error | null; + refetch: () => void; } const PORTFOLIO_API_BASE_URL = 'https://portfolio.api.cx.metamask.io/'; @@ -57,47 +58,51 @@ export const useSitesData = ({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - const fetchSites = async () => { - try { - setIsLoading(true); - setError(null); - - // Use current timestamp - const timestamp = Date.now(); - const url = `${PORTFOLIO_API_BASE_URL}explore/sites?limit=${limit}&ts=${timestamp}`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch sites: ${response.statusText}`); - } - - const data = (await response.json()) as ApiSitesResponse; - - // Transform API response to SiteData format - const transformedSites: SiteData[] = data.dapps.map((dapp) => ({ - id: dapp.id, - name: dapp.name, - url: dapp.website, - displayUrl: extractDisplayUrl(dapp.website), - logoUrl: dapp.logoSrc, - featured: dapp.featured, - })); - - setSites(transformedSites); - } catch (err) { - const fetchError = err instanceof Error ? err : new Error(String(err)); - Logger.error(fetchError, '[useSitesData] Error fetching sites'); - setError(fetchError); - // Don't use fallback data - return empty array to show the error - setSites([]); - } finally { - setIsLoading(false); + const fetchSites = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + // Use current timestamp + const timestamp = Date.now(); + const url = `${PORTFOLIO_API_BASE_URL}explore/sites?limit=${limit}&ts=${timestamp}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch sites: ${response.statusText}`); } - }; - fetchSites(); + const data = (await response.json()) as ApiSitesResponse; + + // Transform API response to SiteData format + const transformedSites: SiteData[] = data.dapps.map((dapp) => ({ + id: dapp.id, + name: dapp.name, + url: dapp.website, + displayUrl: extractDisplayUrl(dapp.website), + logoUrl: dapp.logoSrc, + featured: dapp.featured, + })); + + setSites(transformedSites); + } catch (err) { + const fetchError = err instanceof Error ? err : new Error(String(err)); + Logger.error(fetchError, '[useSitesData] Error fetching sites'); + setError(fetchError); + // Don't use fallback data - return empty array to show the error + setSites([]); + } finally { + setIsLoading(false); + } }, [limit]); - return { sites, isLoading, error }; + useEffect(() => { + fetchSites(); + }, [fetchSites]); + + const refetch = useCallback(() => { + fetchSites(); + }, [fetchSites]); + + return { sites, isLoading, error, refetch }; }; diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index fd5fc276aba1..327b39c09049 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -94,14 +94,17 @@ jest.mock( ); // Mock useTrendingRequest to return empty results -jest.mock('../../../components/UI/Trending/hooks/useTrendingRequest', () => ({ - useTrendingRequest: jest.fn(() => ({ - results: [], - isLoading: false, - error: null, - fetch: jest.fn(), - })), -})); +jest.mock( + '../../../components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest', + () => ({ + useTrendingRequest: jest.fn(() => ({ + results: [], + isLoading: false, + error: null, + fetch: jest.fn(), + })), + }), +); describe('TrendingView', () => { const mockUseSelector = useSelector as jest.MockedFunction< diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 5b24fd6c0a42..5593c6cd6446 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useMemo, useEffect } from 'react'; -import { ScrollView, TouchableOpacity } from 'react-native'; +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -68,6 +68,8 @@ const TrendingFeed: React.FC = () => { const navigation = useNavigation(); const { isEnabled } = useMetrics(); const { colors } = useTheme(); + const [refreshing, setRefreshing] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); // Update state when returning to TrendingFeed useEffect(() => { @@ -106,6 +108,22 @@ const TrendingFeed: React.FC = () => { navigation.navigate(Routes.EXPLORE_SEARCH); }, [navigation]); + // Clean up timeout when component unmounts or refreshing changes + useEffect(() => { + if (refreshing) { + const timeoutId = setTimeout(() => { + setRefreshing(false); + }, 1000); + + return () => clearTimeout(timeoutId); + } + }, [refreshing]); + + const handleRefresh = useCallback(() => { + setRefreshing(true); + setRefreshTrigger((prev) => prev + 1); + }, []); + return ( @@ -147,13 +165,21 @@ const TrendingFeed: React.FC = () => { + } > {HOME_SECTIONS_ARRAY.map((section) => ( - + ))} diff --git a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx index a1239da199a0..3fa1d09bbf32 100644 --- a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx +++ b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useEffect } from 'react'; import { StyleSheet } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; import { useAppThemeFromContext } from '../../../../../util/theme'; @@ -20,15 +20,25 @@ const createStyles = (theme: Theme) => }); interface SectionCardProps { sectionId: SectionId; + refreshTrigger?: number; } -const SectionCard: React.FC = ({ sectionId }) => { +const SectionCard: React.FC = ({ + sectionId, + refreshTrigger, +}) => { const navigation = useNavigation(); const theme = useAppThemeFromContext(); const styles = useMemo(() => createStyles(theme), [theme]); const section = SECTIONS_CONFIG[sectionId]; - const { data, isLoading } = section.useSectionData(); + const { data, isLoading, refetch } = section.useSectionData(); + + useEffect(() => { + if (refreshTrigger && refreshTrigger > 0 && refetch) { + refetch(); + } + }, [refreshTrigger, refetch]); const renderFlatItem: ListRenderItem = useCallback( ({ item }) => , diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx index 3a389de35ead..858c40355fef 100644 --- a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx +++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx @@ -1,6 +1,6 @@ import { Box, BoxBorderColor } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { useRef } from 'react'; +import React, { useRef, useEffect } from 'react'; import { Dimensions } from 'react-native'; import { FlashList, FlashListRef } from '@shopify/flash-list'; import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config'; @@ -13,15 +13,25 @@ const CARD_HEIGHT = 220; export interface SectionCarrouselProps { sectionId: SectionId; + refreshTrigger?: number; } -const SectionCarrousel: React.FC = ({ sectionId }) => { +const SectionCarrousel: React.FC = ({ + sectionId, + refreshTrigger, +}) => { const navigation = useNavigation(); const tw = useTailwind(); const flashListRef = useRef>(null); const section = SECTIONS_CONFIG[sectionId]; - const { data, isLoading } = section.useSectionData(); + const { data, isLoading, refetch } = section.useSectionData(); + + useEffect(() => { + if (refreshTrigger && refreshTrigger > 0 && refetch) { + refetch(); + } + }, [refreshTrigger, refetch]); const skeletonCount = 3; const skeletonData = Array.from({ length: skeletonCount }); diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index 0506d56ef740..989a94b1b1b7 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -1,9 +1,6 @@ import React from 'react'; import type { NavigationProp, ParamListBase } from '@react-navigation/native'; -import type { - TrendingAsset, - SortTrendingBy, -} from '@metamask/assets-controllers'; +import type { TrendingAsset } from '@metamask/assets-controllers'; import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import TrendingTokenRowItem from '../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; @@ -17,20 +14,16 @@ import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigatio import PredictMarketSkeleton from '../../../UI/Predict/components/PredictMarketSkeleton'; import SectionCard from '../components/SectionCard/SectionCard'; import SectionCarrousel from '../components/SectionCarrousel/SectionCarrousel'; -import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest'; -import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; -import { PriceChangeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; import { usePerpsMarkets } from '../../../UI/Perps/hooks'; import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager'; -import { useSearchRequest } from '../../../UI/Trending/hooks/useSearchRequest'; import { Box, IconName } from '@metamask/design-system-react-native'; import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem'; import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper'; import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton'; import { useSitesData } from '../SectionSites/hooks/useSitesData'; -import { CaipChainId } from '@metamask/utils'; +import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites'; @@ -40,12 +33,6 @@ interface SectionData { refetch?: () => void; } -interface SectionParams { - searchQuery?: string; - sortBy?: SortTrendingBy; - chainIds?: CaipChainId[] | null; -} - interface SectionConfig { id: SectionId; title: string; @@ -58,11 +45,11 @@ interface SectionConfig { Skeleton: React.ComponentType; getSearchableText: (item: unknown) => string; keyExtractor: (item: unknown) => string; - Section: React.ComponentType; - useSectionData: (params?: SectionParams) => { + Section: React.ComponentType<{ refreshTrigger?: number }>; + useSectionData: (searchQuery?: string) => { data: unknown[]; isLoading: boolean; - refetch?: () => void; + refetch: () => void; }; } @@ -97,60 +84,13 @@ export const SECTIONS_CONFIG: Record = { getSearchableText: (item) => `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(), keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`, - Section: () => , - useSectionData: (params?: SectionParams) => { - const { searchQuery, sortBy, chainIds } = params ?? {}; - // Trending will return tokens that have just been created which wont be picked up by search API - // so if you see a token on trending and search on omnisearch which uses the search endpoint... - // There is a chance you will get 0 results - const { results: searchResults, isLoading: isSearchLoading } = - useSearchRequest({ - query: searchQuery || '', - limit: 20, - chainIds: [], - }); - - const { - results: trendingResults, - isLoading: isTrendingLoading, - fetch: fetchTrendingTokens, - } = useTrendingRequest({ - sortBy, - chainIds: chainIds ?? undefined, - }); - - if (!searchQuery) { - const sortedResults = sortTrendingTokens( - trendingResults, - PriceChangeOption.PriceChange, - ); - return { - data: sortedResults, - isLoading: isTrendingLoading, - refetch: () => { - fetchTrendingTokens(); - }, - }; - } - - const resultMap = new Map( - trendingResults.map((result) => [result.assetId, result]), - ); - - searchResults.forEach((result) => { - const asset = result as TrendingAsset; - if (!resultMap.has(asset.assetId)) { - resultMap.set(asset.assetId, asset); - } - }); + Section: ({ refreshTrigger }) => ( + + ), + useSectionData: (searchQuery) => { + const { data, isLoading, refetch } = useTrendingSearch(searchQuery); - return { - data: Array.from(resultMap.values()), - isLoading: isSearchLoading, - refetch: () => { - fetchTrendingTokens(); - }, - }; + return { data, isLoading, refetch }; }, }, perps: { @@ -184,17 +124,21 @@ export const SECTIONS_CONFIG: Record = { getSearchableText: (item) => `${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(), keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`, - Section: () => ( + Section: ({ refreshTrigger }) => ( - + ), useSectionData: () => { - const { markets, isLoading } = usePerpsMarkets(); + const { markets, isLoading, refresh, isRefreshing } = usePerpsMarkets(); - return { data: markets, isLoading }; + return { + data: markets, + isLoading: isLoading || isRefreshing, + refetch: refresh, + }; }, }, predictions: { @@ -215,16 +159,20 @@ export const SECTIONS_CONFIG: Record = { getSearchableText: (item) => (item as PredictMarketType).title.toLowerCase(), keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`, - Section: () => , - useSectionData: (params?: SectionParams) => { - const { searchQuery } = params ?? {}; - const { marketData, isFetching } = usePredictMarketData({ + Section: ({ refreshTrigger }) => ( + + ), + useSectionData: (searchQuery) => { + const { marketData, isFetching, refetch } = usePredictMarketData({ category: 'trending', pageSize: searchQuery ? 20 : 6, q: searchQuery || undefined, }); - return { data: marketData, isLoading: isFetching }; + return { data: marketData, isLoading: isFetching, refetch }; }, }, sites: { @@ -241,10 +189,12 @@ export const SECTIONS_CONFIG: Record = { getSearchableText: (item) => `${(item as SiteData).name} ${(item as SiteData).displayUrl}`.toLowerCase(), keyExtractor: (item) => `site-${(item as SiteData).id}`, - Section: () => , + Section: ({ refreshTrigger }) => ( + + ), useSectionData: () => { - const { sites, isLoading } = useSitesData({ limit: 100 }); - return { data: sites, isLoading }; + const { sites, isLoading, refetch } = useSitesData({ limit: 100 }); + return { data: sites, isLoading, refetch }; }, }, }; @@ -277,13 +227,16 @@ export const useSectionsData = ( searchQuery?: string, ): Record => { const { data: trendingTokens, isLoading: isTokensLoading } = - SECTIONS_CONFIG.tokens.useSectionData({ searchQuery }); + SECTIONS_CONFIG.tokens.useSectionData(searchQuery); + const { data: perpsMarkets, isLoading: isPerpsLoading } = - SECTIONS_CONFIG.perps.useSectionData(); + SECTIONS_CONFIG.perps.useSectionData(searchQuery); + const { data: predictionMarkets, isLoading: isPredictionsLoading } = - SECTIONS_CONFIG.predictions.useSectionData({ searchQuery }); + SECTIONS_CONFIG.predictions.useSectionData(searchQuery); + const { data: sites, isLoading: isSitesLoading } = - SECTIONS_CONFIG.sites.useSectionData(); + SECTIONS_CONFIG.sites.useSectionData(searchQuery); return { tokens: { From 22f38c548f1431fb1a857dbafe5dd4243dfb1be3 Mon Sep 17 00:00:00 2001 From: Andre Pimenta Date: Tue, 25 Nov 2025 19:53:06 +0000 Subject: [PATCH 6/9] feat(predict): cp-7.60.0 append utm_source to entryPoint in predict deeplinks (#23275) ## **Description** This PR enhances the predict deeplink handler to include UTM source tracking in the `entryPoint` parameter passed to screens. **What changed:** - Parse `utm_source` from predict deeplink URL parameters - Append `utm_source` to the `entryPoint` (e.g., `deeplink_test`, `carousel_twitter`) - Skip appending if `utm_source` equals the base `entryPoint` to avoid duplicates like `deeplink_deeplink` **Why:** This allows analytics to track the specific UTM source that brought users to the predict feature via deeplinks, enabling better attribution and campaign performance measurement. **Examples:** | URL | Origin | entryPoint | |-----|--------|------------| | `predict?utm_source=test` | - | `deeplink_test` | | `predict?utm_source=twitter` | `carousel` | `carousel_twitter` | | `predict?utm_source=deeplink` | - | `deeplink` (no duplicate) | ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Predict deeplink utm_source tracking Scenario: user opens predict deeplink with utm_source parameter Given the app is installed and user is logged in When user opens deeplink "https://metamask.app.link/predict?utm_source=test" Then user navigates to predict market list And entryPoint is set to "deeplink_test" Scenario: user opens predict deeplink with market and utm_source Given the app is installed and user is logged in When user opens deeplink "https://metamask.app.link/predict?marketId=1231&utm_source=twitter" Then user navigates to predict market details for market 1231 And entryPoint is set to "deeplink_twitter" Scenario: user opens predict deeplink with utm_source matching origin Given the app is installed and user is logged in When user opens deeplink "https://metamask.app.link/predict?utm_source=deeplink" Then user navigates to predict market list And entryPoint is set to "deeplink" (not "deeplink_deeplink") ``` ## **Screenshots/Recordings** ### **Before** N/A - No UI changes ### **After** N/A - No UI changes ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Parse utm_source from predict deeplinks and append it to the entryPoint (unless equal to base), updating navigation behavior and logs with comprehensive tests. > > - **Core: `app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts`** > - Parse `utm_source` from query into `PredictNavigationParams`. > - Derive `entryPoint` from `origin` (or `deeplink`) and append `_` when present and different. > - Apply computed `entryPoint` to both market details and market list navigations. > - Enhance logging to include parsed `utmSource` and final `entryPoint`. > - **Tests: `__tests__/handlePredictUrl.test.ts`** > - Update expectations to reflect `entryPoint` suffixing when `utm_source` exists. > - Add cases for multiple params, various origins (carousel/deeplink/notification), equality guard (no duplicate), and no-market with `utm_source`. > - Verify logs include `utmSource` in parsed params and `entryPoint` output. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0c6638143428c2cafce018a5857f172f6f5a646b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../legacy/__tests__/handlePredictUrl.test.ts | 153 +++++++++++++++++- .../handlers/legacy/handlePredictUrl.ts | 29 +++- 2 files changed, 167 insertions(+), 15 deletions(-) diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts index 88ff6221446c..69fbd26e4d0f 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts @@ -74,7 +74,7 @@ describe('handlePredictUrl', () => { }); }); - it('handles multiple URL parameters', async () => { + it('handles multiple URL parameters with utm_source in entryPoint', async () => { await handlePredictUrl({ predictPath: '?market=xyz123&utm_source=campaign&debug=true', }); @@ -83,7 +83,7 @@ describe('handlePredictUrl', () => { screen: Routes.PREDICT.MARKET_DETAILS, params: { marketId: 'xyz123', - entryPoint: 'deeplink', + entryPoint: 'deeplink_campaign', }, }); }); @@ -158,13 +158,13 @@ describe('handlePredictUrl', () => { }); }); - it('navigates to market list when only other parameters provided', async () => { + it('navigates to market list with utm_source in entryPoint when only utm_source provided', async () => { await handlePredictUrl({ predictPath: '?utm_source=campaign' }); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, params: { - entryPoint: 'deeplink', + entryPoint: 'deeplink_campaign', }, }); }); @@ -247,7 +247,7 @@ describe('handlePredictUrl', () => { expect(DevLogger.log).toHaveBeenCalledWith( '[handlePredictUrl] Parsed navigation parameters:', - { market: '23246' }, + { market: '23246', utmSource: undefined }, ); }); @@ -346,7 +346,7 @@ describe('handlePredictUrl', () => { }); }); - it('sets entryPoint to deeplink when origin is undefined', async () => { + it('sets entryPoint to deeplink when origin is undefined and no utm_source', async () => { await handlePredictUrl({ predictPath: '?market=23246', origin: undefined, @@ -361,7 +361,7 @@ describe('handlePredictUrl', () => { }); }); - it('sets entryPoint to deeplink when origin is deeplink', async () => { + it('sets entryPoint to deeplink when origin is deeplink and no utm_source', async () => { await handlePredictUrl({ predictPath: '?market=23246', origin: 'deeplink', @@ -409,4 +409,143 @@ describe('handlePredictUrl', () => { ); }); }); + + describe('utm_source parameter handling', () => { + it('sets entryPoint to deeplink_test when utm_source is test', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=test', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'deeplink_test', + }, + }); + }); + + it('sets entryPoint to deeplink_twitter when utm_source is twitter', async () => { + await handlePredictUrl({ + predictPath: '?marketId=12345&utm_source=twitter', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '12345', + entryPoint: 'deeplink_twitter', + }, + }); + }); + + it('appends utm_source to carousel origin', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=test', + origin: 'carousel', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'carousel_test', + }, + }); + }); + + it('appends utm_source to deeplink origin', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=test', + origin: 'deeplink', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'deeplink_test', + }, + }); + }); + + it('appends utm_source to notification origin', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=campaign', + origin: 'notification', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'notification_campaign', + }, + }); + }); + + it('does not append utm_source when it equals origin', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=carousel', + origin: 'carousel', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'carousel', + }, + }); + }); + + it('does not append utm_source when it equals default deeplink', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=deeplink', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'deeplink', + }, + }); + }); + + it('navigates to market list with deeplink_test entryPoint when no market but utm_source present', async () => { + await handlePredictUrl({ + predictPath: '?utm_source=test', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: 'deeplink_test', + }, + }); + }); + + it('logs parsed utm_source in navigation parameters', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=test', + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] Parsed navigation parameters:', + { market: '23246', utmSource: 'test' }, + ); + }); + + it('logs entry point with utm_source suffix', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&utm_source=test', + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] Entry point:', + 'deeplink_test', + ); + }); + }); }); diff --git a/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts index 65e36b17ead7..f835cd92d231 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts @@ -12,6 +12,7 @@ interface HandlePredictUrlParams { */ interface PredictNavigationParams { market?: string; // Market ID + utmSource?: string; // UTM source for analytics tracking } /** @@ -28,9 +29,11 @@ const parsePredictNavigationParams = ( // Support both 'market' and 'marketId' parameter names const marketId = urlParams.get('market') || urlParams.get('marketId'); + const utmSource = urlParams.get('utm_source'); return { market: marketId || undefined, + utmSource: utmSource || undefined, }; }; @@ -76,13 +79,14 @@ const handleMarketNavigation = (marketId: string, entryPoint: string) => { * - https://metamask.app.link/predict * - https://metamask.app.link/predict?market=23246 * - https://metamask.app.link/predict?marketId=23246 + * - https://metamask.app.link/predict?market=23246&utm_source=test * - https://link.metamask.io/predict?market=23246 * - https://link.metamask.io/predict?marketId=23246 * - * Origin handling: - * - Uses origin value directly as entryPoint for analytics tracking - * - Defaults to 'deeplink' if origin is not provided - * - Examples: 'carousel', 'notification', 'deeplink', etc. + * Origin/EntryPoint handling: + * - Base entryPoint is origin if provided, otherwise 'deeplink' + * - If utm_source is present, always appends '_' + utm_source to the base + * - Examples: 'deeplink', 'deeplink_test', 'carousel_twitter', 'notification_campaign' * * Navigation behavior: * - No market param: Navigate to market list @@ -100,10 +104,6 @@ export const handlePredictUrl = async ({ ); try { - // Use origin as entry point, default to 'deeplink' if not provided - const entryPoint = origin || 'deeplink'; - DevLogger.log('[handlePredictUrl] Entry point:', entryPoint); - // Parse navigation parameters from URL const navParams = parsePredictNavigationParams(predictPath); DevLogger.log( @@ -111,6 +111,19 @@ export const handlePredictUrl = async ({ navParams, ); + // Determine entry point: + // - Base is origin if provided, otherwise 'deeplink' + // - If utm_source is present and different from base, append '_' + utm_source + // - If utm_source equals base, don't append (avoid 'deeplink_deeplink') + // - Examples: 'deeplink_test', 'carousel_twitter', 'notification_campaign' + const baseEntryPoint = origin || 'deeplink'; + const shouldAppendUtmSource = + navParams.utmSource && navParams.utmSource !== baseEntryPoint; + const entryPoint = shouldAppendUtmSource + ? `${baseEntryPoint}_${navParams.utmSource}` + : baseEntryPoint; + DevLogger.log('[handlePredictUrl] Entry point:', entryPoint); + // If market ID is provided, navigate to market details if (navParams.market) { handleMarketNavigation(navParams.market, entryPoint); From e2e14e72d6a56fe84749ee86c9e25941b97b4b1d Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Tue, 25 Nov 2025 12:57:59 -0700 Subject: [PATCH 7/9] chore: condense market data disclaimer copy (#23229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Condense market data disclaimer copy ## **Changelog** CHANGELOG entry: Update market data disclaimer copy ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MDP-234 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** `~` ### **Before** Simulator Screenshot - iPhone 16 Pro Max -
2025-11-24 at 17 30 31 ### **After** Simulator Screenshot - iPhone 16 Pro Max -
2025-11-24 at 17 35 53 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Shortens the asset market data disclaimer and updates related snapshots and English locale string. > > - **Asset View**: > - **Copy**: Shortens market data disclaimer displayed in `Asset` view. > - **Tests**: Updates `__snapshots__/index.test.js.snap` to reflect new disclaimer text. > - **Localization**: > - Updates `locales/languages/en.json` `asset_overview.disclaimer` string. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b25843d23cc3575c0252842165a5268664ddfeb6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Asset/__snapshots__/index.test.js.snap | 16 ++++++++-------- locales/languages/en.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/components/Views/Asset/__snapshots__/index.test.js.snap b/app/components/Views/Asset/__snapshots__/index.test.js.snap index 7c3dfce5c3e7..af3d82419ea3 100644 --- a/app/components/Views/Asset/__snapshots__/index.test.js.snap +++ b/app/components/Views/Asset/__snapshots__/index.test.js.snap @@ -1992,7 +1992,7 @@ exports[`Asset Multichain Functionality should exclude mixed token/SOL transacti } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -3766,7 +3766,7 @@ exports[`Asset Multichain Functionality should exclude transactions with empty a } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -5581,7 +5581,7 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -7355,7 +7355,7 @@ exports[`Asset Multichain Functionality should filter native SOL transactions co } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -9129,7 +9129,7 @@ exports[`Asset Multichain Functionality should handle state with no multichain t } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -10944,7 +10944,7 @@ exports[`Asset Multichain Functionality should handle unknown SPL token filterin } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -13248,7 +13248,7 @@ exports[`Asset Multichain Functionality should render non-EVM assets with Multic } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. @@ -15022,7 +15022,7 @@ exports[`Asset Multichain Functionality should sort filtered transactions by tim } } > - Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy. + Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy. diff --git a/locales/languages/en.json b/locales/languages/en.json index e2958f9be35f..54f24dcaed19 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3130,7 +3130,7 @@ "show_less": "Show less" }, "activity": "{{symbol}} activity", - "disclaimer": "Market data is provided by one or more third-party data sources, including CoinGecko. Such third-party content is provided solely for informational purposes and should not be treated as advice to buy, sell, or use any particular asset. MetaMask does not suggest the use of this content for any particular purpose and is not responsible for its accuracy." + "disclaimer": "Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy." }, "account_details": { "title": "Account Details", From 2ec593df665796fdee9d0b029be2fca5bae6fb8b Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Tue, 25 Nov 2025 22:30:04 +0100 Subject: [PATCH 8/9] fix: cp-7.59.1 cp-7.60.0 add mon to the currency list (#23269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** add mon currency to the currencies list ## **Changelog** CHANGELOG entry: add mon currency to currencies list ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2025-11-25 at 18 47 45 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Normalizes account keys to lowercase in TokenBalancesController and adds 'mon' to supported currencies. > > - **Assets Controllers**: > - **TokenBalancesController**: Normalize account keys to lowercase when reading/writing `d.tokenBalances` in `dist/TokenBalancesController.{cjs,mjs}` to ensure consistent balance updates. > - **Token Prices Service**: > - Add `"mon"` (Monad) to `SUPPORTED_CURRENCIES` in `dist/token-prices-service/codefi-v2.cjs`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d9452adccc683dd35fd345becaf613fa33561140. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...k-assets-controllers-npm-89.0.1-02fa7acd54.patch | 13 +++++++++++++ yarn.lock | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch b/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch index 21be6c303395..2fe517135290 100644 --- a/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch +++ b/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch @@ -46,3 +46,16 @@ index f64d13f8de56631345a44e6ebb025e62e03f51bc..99aa7f27c574c94b26daa56091ac50d1 } } }); +diff --git a/dist/token-prices-service/codefi-v2.cjs b/dist/token-prices-service/codefi-v2.cjs +index 34f7bcf4dea1b8d6a1ea45051be09059d9d35353..6aa82360e63727852cda1719f5e893508b764e75 100644 +--- a/dist/token-prices-service/codefi-v2.cjs ++++ b/dist/token-prices-service/codefi-v2.cjs +@@ -98,6 +98,8 @@ exports.SUPPORTED_CURRENCIES = [ + 'mxn', + // Malaysian Ringgit + 'myr', ++ // Monad ++ 'mon', + // Nigerian Naira + 'ngn', + // Norwegian Krone diff --git a/yarn.lock b/yarn.lock index c89428f99e3c..37ac84c03151 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7462,7 +7462,7 @@ __metadata: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch": version: 89.0.1 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch::version=89.0.1&hash=6be0d3" + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch::version=89.0.1&hash=fa830a" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7508,7 +7508,7 @@ __metadata: "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/b936b09bc22944626b3332844070c0fab559b7e3973873cc96c8321618e6879c1ee1215a588bb1f8f38029e4a69f796d01141ad1cb0726fd590df54ca111355b + checksum: 10/0f8c82256141e95b591b0bfff17cf6015ff9e0e4b330e68e95066319d0e415320a4eca48cb6d0f3bf27f3ff68d231ea1c656aa08844bf7699624ef09cd3ed587 languageName: node linkType: hard From 1b4206c30e51928225573d98371e921fe826fe9d Mon Sep 17 00:00:00 2001 From: Curtis David Date: Tue, 25 Nov 2025 18:05:30 -0500 Subject: [PATCH 9/9] test: add e2e to open predict position (#23081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The purpose of this task is to add an e2e that opens a predict position > Adds an end-to-end test for opening a prediction position with comprehensive Polymarket mocks and minor selector/testID updates. > > - **E2E Tests**: > - Add `e2e/specs/predict/predict-open-position.spec.ts` to automate opening a position (Celtics vs. Nets), verify balance update, positions, and activity. > - Remove legacy `predict-select-bet.spec.ts`. > - **Mocks & Test Data (Polymarket)**: > - Introduce extensive mocks for opening positions: `POLYMARKET_POST_OPEN_POSITION_MOCKS`, dynamic activity/positions injectors, and balance refresh (`open-position`). > - Add Celtics/Nets data to feeds, event details, positions, activity, prices, and order book; ensure stable pricing/liquidity and success responses. > - Relax/standardize proxy URL matching across mocks and handle USDC `balanceOf`/`allowance`. > - **Selectors & Pages**: > - Add `PredictBuyPreviewSelectorsIDs.PLACE_BET_BUTTON` and wire to `PredictBuyPreview.tsx`. > - Expose market-list back button selector; enhance PredictDetails/MarketList page objects with actions (tap amount, Done, Continue, Place bet, Back). > - **Misc**: > - Add new USDC post-open-position balance constant; minor utility/test helper updates for feature flags and flows. > ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds an end-to-end test for opening a prediction position with comprehensive Polymarket mocks and minor selector/testID updates. > > - **E2E Tests**: > - Add `e2e/specs/predict/predict-open-position.spec.ts` to automate opening a position (Celtics vs. Nets), verify balance update, positions, and activity. > - Remove legacy `predict-select-bet.spec.ts`. > - **Mocks & Test Data (Polymarket)**: > - Introduce extensive mocks for opening positions: `POLYMARKET_POST_OPEN_POSITION_MOCKS`, dynamic activity/positions injectors, and balance refresh (`open-position`). > - Add Celtics/Nets data to feeds, event details, positions, activity, prices, and order book; ensure stable pricing/liquidity and success responses. > - Relax/standardize proxy URL matching across mocks and handle USDC `balanceOf`/`allowance`. > - **Selectors & Pages**: > - Add `PredictBuyPreviewSelectorsIDs.PLACE_BET_BUTTON` and wire to `PredictBuyPreview.tsx`. > - Expose market-list back button selector; enhance PredictDetails/MarketList page objects with actions (tap amount, Done, Continue, Place bet, Back). > - **Misc**: > - Add new USDC post-open-position balance constant; minor utility/test helper updates for feature flags and flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8005f0c6f96870e88544eb58e4b18c230e7b0767. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictBuyPreview/PredictBuyPreview.tsx | 2 + .../polymarket-sports-feed.ts | 1465 +++++++++++++++++ .../polymarket-activity-response.ts | 28 + .../polymarket/polymarket-constants.ts | 5 + .../polymarket-event-details-response.ts | 400 +++++ .../polymarket/polymarket-mocks.ts | 528 +++++- .../polymarket-order-book-response.ts | 24 + .../polymarket-positions-response.ts | 34 + e2e/pages/Predict/PredictDetailsPage.ts | 57 + e2e/pages/Predict/PredictMarketList.ts | 9 + e2e/selectors/Predict/Predict.selectors.ts | 11 +- .../predict/predict-open-position.spec.ts | 117 ++ e2e/specs/predict/predict-select-bet.spec.ts | 52 - 13 files changed, 2634 insertions(+), 98 deletions(-) create mode 100644 e2e/specs/predict/predict-open-position.spec.ts delete mode 100644 e2e/specs/predict/predict-select-bet.spec.ts diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index 23ed6a693471..635f4b2a9322 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -65,6 +65,7 @@ import ButtonHero from '../../../../../component-library/components-temp/Buttons import { usePredictRewards } from '../../hooks/usePredictRewards'; import { TraceName } from '../../../../../util/trace'; import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; +import { PredictBuyPreviewSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; const PredictBuyPreview = () => { const tw = useTailwind(); @@ -473,6 +474,7 @@ const PredictBuyPreview = () => { return ( (); + /** * Mock Priority System * Higher numbers = checked first (higher priority) @@ -153,10 +161,7 @@ export const POLYMARKET_EVENT_DETAILS_MOCKS = async (mockServer: Mockttp) => { .forGet('/proxy') .matching((request) => { const url = new URL(request.url).searchParams.get('url'); - return Boolean( - url && - /^https:\/\/gamma-api\.polymarket\.com\/events\/[0-9]+$/.test(url), - ); + return Boolean(url?.includes('gamma-api.polymarket.com/events/')); }) .thenCallback((request) => { const url = new URL(request.url).searchParams.get('url'); @@ -171,6 +176,14 @@ export const POLYMARKET_EVENT_DETAILS_MOCKS = async (mockServer: Mockttp) => { }; } + if (eventId === '79682') { + // Return Celtics vs Nets event details from mock response file + return { + statusCode: 200, + json: POLYMARKET_EVENT_DETAILS_CELTICS_NETS_RESPONSE, + }; + } + // Default to Blue Jays vs Mariners for other event IDs return { statusCode: 200, @@ -192,9 +205,8 @@ export const POLYMARKET_CURRENT_POSITIONS_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/positions\?.*user=0x[a-fA-F0-9]{40}.*$/.test( - url, - ), + url.includes('data-api.polymarket.com/positions') && + url.includes('user=0x'), ); }) .asPriority(PRIORITY.BASE) @@ -245,9 +257,8 @@ export const POLYMARKET_POSITIONS_WITH_WINNINGS_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/positions\?.*user=0x[a-fA-F0-9]{40}.*$/.test( - url, - ) && + url.includes('data-api.polymarket.com/positions') && + url.includes('user=0x') && !url.includes('redeemable=true'), ); }) @@ -288,9 +299,8 @@ export const POLYMARKET_POSITIONS_WITH_WINNINGS_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/positions\?.*user=0x[a-fA-F0-9]{40}.*$/.test( - url, - ) && + url.includes('data-api.polymarket.com/positions') && + url.includes('user=0x') && url.includes('redeemable=true'), ); }) @@ -388,7 +398,6 @@ export const POLYMARKET_PRICES_MOCKS = async (mockServer: Mockttp) => { '110743925263777693447488608878982152642205002490046349037358337248548507433643' ) { // Best ask (BUY) = 0.62, Best bid (SELL) = 0.61 - // Using mid price for display: (0.62 + 0.61) / 2 = 0.615, but for accuracy use best ask for BUY and best bid for SELL pricesResponse[tokenId] = { BUY: '0.62', // Best ask - what you'd pay to buy SELL: '0.61', // Best bid - what you'd receive to sell @@ -405,6 +414,29 @@ export const POLYMARKET_PRICES_MOCKS = async (mockServer: Mockttp) => { SELL: '0.37', // Best bid - what you'd receive to sell }; } + // Celtics token (Celtics vs Nets market) + else if ( + tokenId === + '51851880223290407825872150827934296608070009371891114025629582819868766043137' + ) { + // Best ask (BUY) = 0.84, Best bid (SELL) = 0.83 (from HAR file) + pricesResponse[tokenId] = { + BUY: '0.84', // Best ask - what you'd pay to buy + SELL: '0.83', // Best bid - what you'd receive to sell + }; + } + // Nets token (Celtics vs Nets market) + else if ( + tokenId === + '51090123154876409384652748958994213129207000557350215937559106819875795938227' + ) { + // Best ask (BUY) = 0.17, Best bid (SELL) = 0.17 + // The app displays the SELL price (entry.sell), so both should be 0.17 to show 17¢ + pricesResponse[tokenId] = { + BUY: '0.17', // Best ask - what you'd pay to buy + SELL: '0.17', // Best bid - what you'd receive to sell (this is what's displayed) + }; + } // Default prices for other tokens (can be extended as needed) else { pricesResponse[tokenId] = { @@ -432,7 +464,8 @@ export const POLYMARKET_ORDER_BOOK_MOCKS = async (mockServer: Mockttp) => { const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/clob\.polymarket\.com\/book\?token_id=\d+$/.test(url), + url.includes('clob.polymarket.com/book') && + url.includes('token_id='), ); }) .asPriority(PRIORITY.BASE) @@ -486,6 +519,12 @@ export const POLYMARKET_ORDER_BOOK_MOCKS = async (mockServer: Mockttp) => { ) { // Pelicans token orderBookResponse = POLYMARKET_PELICANS_ORDER_BOOK_RESPONSE; + } else if ( + tokenId === + '51851880223290407825872150827934296608070009371891114025629582819868766043137' + ) { + // Celtics token (Celtics vs Nets) + orderBookResponse = POLYMARKET_CELTICS_ORDER_BOOK_RESPONSE; } else { // Default to 76ers for unknown token IDs orderBookResponse = POLYMARKET_ORDER_BOOK_RESPONSE; @@ -557,9 +596,8 @@ export const POLYMARKET_ACTIVITY_MOCKS = async (mockServer: Mockttp) => { const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/activity\?.*user=0x[a-fA-F0-9]{40}/.test( - url, - ), + url.includes('data-api.polymarket.com/activity') && + url.includes('user=0x'), ); }) .asPriority(PRIORITY.BASE) @@ -591,9 +629,8 @@ export const POLYMARKET_UPNL_MOCKS = async (mockServer: Mockttp) => { const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/upnl\?user=0x[a-fA-F0-9]{40}$/.test( - url, - ), + url.includes('data-api.polymarket.com/upnl') && + url.includes('user=0x'), ); }) .asPriority(PRIORITY.BASE) @@ -690,8 +727,19 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( } else if ( toAddress?.toLowerCase() === USDC_CONTRACT_ADDRESS.toLowerCase() ) { - // USDC contract call - return current global balance - result = currentUSDCBalance; + // USDC contract call - check function selector + if (callData?.toLowerCase()?.startsWith('0x70a08231')) { + // balanceOf(address) selector - return current global balance + result = currentUSDCBalance; + } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { + // allowance(address,address) selector - return max allowance (uint256 max) + // This indicates full allowance is granted + result = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + } else { + // Other USDC contract calls - return current global balance as fallback + result = currentUSDCBalance; + } } else if ( toAddress?.toLowerCase() === MULTICALL_CONTRACT_ADDRESS.toLowerCase() ) { @@ -781,8 +829,7 @@ export const POLYMARKET_MARKET_FEEDS_MOCKS = async (mockServer: Mockttp) => { .matching((request) => { const url = new URL(request.url).searchParams.get('url'); return Boolean( - url && - /^https:\/\/gamma-api\.polymarket\.com\/events\/pagination/.test(url), + url?.includes('gamma-api.polymarket.com/events/pagination'), ); }) .asPriority(PRIORITY.BASE) @@ -839,9 +886,7 @@ export const POLYMARKET_MARKET_FEEDS_MOCKS = async (mockServer: Mockttp) => { .forGet('/proxy') .matching((request) => { const url = new URL(request.url).searchParams.get('url'); - return Boolean( - url && /^https:\/\/gamma-api\.polymarket\.com\/public-search/.test(url), - ); + return Boolean(url?.includes('gamma-api.polymarket.com/public-search')); }) .asPriority(PRIORITY.BASE) .thenCallback(() => ({ @@ -904,6 +949,152 @@ export const POLYMARKET_TRANSACTION_SENTINEL_MOCKS = async ( } }); }; + +/** + * Mock for adding Celtics vs Nets position to positions list after order is submitted + * This override adds the Celtics vs Nets position only after the open position flow is completed + * + * Mocks endpoint: https://data-api.polymarket.com/positions?limit=100&offset=0&user=...&sortBy=CURRENT&redeemable=false&eventId=79682 + * - Always uses PROXY_WALLET_ADDRESS for the proxyWallet field (regardless of user in URL) + * - When eventId=79682 (Celtics vs Nets), returns only the Celtics position + * - When no eventId, returns all positions including Celtics (if order was submitted) + * - Also mocks the position appearing in the main positions list on the predict page + * + * @param mockServer - The mockttp server instance + */ +export const POLYMARKET_ADD_CELTICS_POSITION_MOCKS = async ( + mockServer: Mockttp, +) => { + await mockServer + .forGet('/proxy') + .matching((request) => { + const url = new URL(request.url).searchParams.get('url'); + return Boolean( + url && + url.includes('data-api.polymarket.com/positions') && + url.includes('user=0x') && + !url.includes('redeemable=true'), + ); + }) + .asPriority(PRIORITY.API_OVERRIDE) // Higher priority to override the base positions mock + .thenCallback((request) => { + const url = new URL(request.url).searchParams.get('url'); + const eventIdMatch = url?.match(/eventId=([0-9]+)/); + const eventId = eventIdMatch ? eventIdMatch[1] : null; + + // Check if Celtics vs Nets order has been submitted + const proxyAddressLower = PROXY_WALLET_ADDRESS.toLowerCase(); + const celticsOrderSubmittedForProxy = + celticsOrderSubmitted.has(proxyAddressLower); + + // If eventId=79682 (Celtics vs Nets), return only the Celtics position + if (eventId === '79682') { + if (!celticsOrderSubmittedForProxy) { + // Return empty array if order hasn't been submitted yet + return { + statusCode: 200, + json: [], + }; + } + + // Return Celtics vs Nets position with PROXY_WALLET_ADDRESS + const dynamicResponse = + POLYMARKET_NEW_OPEN_POSITION_CELTICS_NETS_RESPONSE.map( + (position) => ({ + ...position, + proxyWallet: PROXY_WALLET_ADDRESS, + }), + ); + + return { + statusCode: 200, + json: dynamicResponse, + }; + } + + // For main positions list (no eventId filter), combine existing positions with Celtics position + // only if Celtics order was submitted. Put Celtics position at the top of the list. + let allPositions = [...POLYMARKET_CURRENT_POSITIONS_RESPONSE]; + if (celticsOrderSubmittedForProxy) { + allPositions = [ + ...POLYMARKET_NEW_OPEN_POSITION_CELTICS_NETS_RESPONSE, + ...POLYMARKET_CURRENT_POSITIONS_RESPONSE, + ]; + } + + // Filter positions by eventId if provided (for other eventIds) + let filteredPositions = allPositions; + if (eventId) { + filteredPositions = allPositions.filter( + (position) => position.eventId === eventId, + ); + } + + // Always use PROXY_WALLET_ADDRESS for proxyWallet field + const dynamicResponse = filteredPositions.map((position) => ({ + ...position, + proxyWallet: PROXY_WALLET_ADDRESS, + })); + + return { + statusCode: 200, + json: dynamicResponse, + }; + }); +}; + +/** + * Mock for adding Celtics vs Nets activity entry to activity list after order is submitted + * This override adds the Celtics vs Nets BUY activity only after the open position flow is completed + * @param mockServer - The mockttp server instance + */ +export const POLYMARKET_ADD_CELTICS_ACTIVITY_MOCKS = async ( + mockServer: Mockttp, +) => { + await mockServer + .forGet('/proxy') + .matching((request) => { + const url = new URL(request.url).searchParams.get('url'); + return Boolean( + url && + url.includes('data-api.polymarket.com/activity') && + url.includes('user=0x'), + ); + }) + .asPriority(PRIORITY.API_OVERRIDE) // Higher priority to override the base activity mock + .thenCallback((request) => { + const url = new URL(request.url).searchParams.get('url'); + const userMatch = url?.match(/user=(0x[a-fA-F0-9]{40})/); + const userAddress = userMatch ? userMatch[1] : USER_WALLET_ADDRESS; + + // Check if Celtics vs Nets order has been submitted + const proxyAddressLower = PROXY_WALLET_ADDRESS.toLowerCase(); + const celticsOrderSubmittedForProxy = + celticsOrderSubmitted.has(proxyAddressLower); + + // Combine existing activity with Celtics activity only if Celtics order was submitted + // Put Celtics activity at the top (most recent first) + let allActivity = [...POLYMARKET_ACTIVITY_RESPONSE]; + if (celticsOrderSubmittedForProxy) { + allActivity = [ + ...POLYMARKET_OPENED_POSITION_ACTIVITY_RESPONSE, + ...POLYMARKET_ACTIVITY_RESPONSE, + ]; + } + + // Update the mock response with the actual user address + const dynamicResponse = allActivity.map((activity) => ({ + ...activity, + proxyWallet: userAddress, + })); + + return { + statusCode: 200, + json: dynamicResponse, + }; + }); +}; + /** * Sets up mocks for USDC balance refresh calls after claim or cash-out operations * This mock should be triggered after claim/cash-out transactions to update the displayed balance @@ -925,6 +1116,8 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( balance = POST_CLAIM_USDC_BALANCE_WEI; // 48.16 USDC } else if (positionType === 'cash-out') { balance = POST_CASH_OUT_USDC_BALANCE_WEI; // 58.66 USDC + } else if (positionType === 'open-position') { + balance = POST_OPEN_POSITION_USDC_BALANCE_WEI; // 17.76 USDC } else { throw new Error(`Unknown positionType: ${positionType}`); } @@ -947,9 +1140,20 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( // Handle USDC balance calls if (body?.method === 'eth_call') { const toAddress = body?.params?.[0]?.to?.toLowerCase(); + const callData = body?.params?.[0]?.data; if (toAddress === USDC_CONTRACT_ADDRESS.toLowerCase()) { - // USDC contract call - return updated balance - result = balance; + // USDC contract call - check function selector + if (callData?.toLowerCase()?.startsWith('0x70a08231')) { + // balanceOf(address) selector - return updated balance + result = balance; + } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { + // allowance(address,address) selector - return max allowance (uint256 max) + result = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + } else { + // Other USDC contract calls - return updated balance as fallback + result = balance; + } } else { // For other eth_call, return empty result (let base mocks handle if needed) result = MOCK_RPC_RESPONSES.EMPTY_RESULT; @@ -995,9 +1199,11 @@ export const POLYMARKET_POST_CASH_OUT_MOCKS = async (mockServer: Mockttp) => { .matching(async (request) => { try { const urlParam = new URL(request.url).searchParams.get('url'); - const relayerEndpointPattern = - /predict\.(dev-)?api\.cx\.metamask\.io\/order/; - if (!urlParam || !relayerEndpointPattern.test(urlParam)) { + if ( + !urlParam || + !urlParam.includes('predict.') || + !urlParam.includes('api.cx.metamask.io/order') + ) { return false; } @@ -1079,6 +1285,242 @@ export const POLYMARKET_POST_CASH_OUT_MOCKS = async (mockServer: Mockttp) => { await POLYMARKET_UPDATE_USDC_BALANCE_MOCKS(mockServer, 'cash-out'); }; +/** + * Mocks for opening a position (BUY order) and balance update + * This mock should be triggered before placing the order + * - Mocks the MetaMask relayer endpoint (predict.dev-api.cx.metamask.io/order) + * - Updates global USDC balance to post-open-position amount (18.11 USDC) + * - Adds position and activity only AFTER the order is successfully submitted + * Note: Celtics vs Nets is available in the sports feed, so no search mock is needed + * @param mockServer - The mockttp server instance + */ +export const POLYMARKET_POST_OPEN_POSITION_MOCKS = async ( + mockServer: Mockttp, +) => { + // Track whether the order has been successfully submitted + // This ensures the position only appears AFTER the order is placed + const orderSubmitted = new Set(); + + // Mock MetaMask relayer endpoint for order submission (BUY orders) + // In e2e, all requests go through /proxy with the actual URL in the url query parameter + // Matches request payload structure with PROXY_WALLET_ADDRESS as maker and USER_WALLET_ADDRESS as signer + // Response uses decimal string format (not wei) + // Uses flexible matching: requires BUY order to relayer endpoint, with optional strict field validation + await mockServer + .forPost('/proxy') + .matching(async (request) => { + try { + const urlParam = new URL(request.url).searchParams.get('url'); + if ( + !urlParam || + !urlParam.includes('predict.') || + !urlParam.includes('api.cx.metamask.io/order') + ) { + return false; + } + + const bodyText = await request.body.getText(); + const body = bodyText ? JSON.parse(bodyText) : {}; + const order = body?.order; + + // Flexible matching: require BUY order to relayer endpoint + // Validates key fields when present, but doesn't require all fields to match strict pattern + // This handles both well-formed orders and edge cases with missing/optional fields + if (!order || order.side !== 'BUY') { + return false; + } + + // Validate orderType if present (should be FOK for open positions) + if (body.orderType !== undefined && body.orderType !== 'FOK') { + return false; + } + + // Validate addresses if present (should match expected addresses for open positions) + if (order.maker !== undefined && order.signer !== undefined) { + const makerMatch = + order.maker?.toLowerCase() === PROXY_WALLET_ADDRESS.toLowerCase(); + const signerMatch = + order.signer?.toLowerCase() === USER_WALLET_ADDRESS.toLowerCase(); + if (!makerMatch || !signerMatch) { + return false; + } + } + + // If order has signature field, validate it's a valid signature format + if (order.signature !== undefined) { + if ( + typeof order.signature !== 'string' || + !order.signature.startsWith('0x') || + order.signature.length < 10 + ) { + return false; + } + } + + return true; + } catch { + return false; + } + }) + .asPriority(PRIORITY.API_OVERRIDE) + .thenCallback(async (request) => { + try { + const bodyText = await request.body.getText(); + const body = bodyText ? JSON.parse(bodyText) : {}; + const order = body?.order; + const userAddress = + order?.signer?.toLowerCase() || USER_WALLET_ADDRESS.toLowerCase(); + const proxyAddress = + order?.maker?.toLowerCase() || PROXY_WALLET_ADDRESS.toLowerCase(); + + // Check if it's a Celtics vs Nets token + const isCelticsToken = + order?.tokenId === + '51851880223290407825872150827934296608070009371891114025629582819868766043137'; + + // Track both addresses - positions/activity may use either + orderSubmitted.add(userAddress); + orderSubmitted.add(proxyAddress); + + // Track Celtics orders separately for position addition + if (isCelticsToken) { + celticsOrderSubmitted.add(userAddress); + celticsOrderSubmitted.add(proxyAddress); + } + + return { + statusCode: 200, + json: { + success: true, + errorMsg: '', + status: 'matched', + orderID: + '0x3bd7640f8ec62a31ab9f95f0b94582d3a7fb159dbaed773eb5fcca45c43bcdb9', + transactionsHashes: [ + '0x6a14089acbb670682a700ba57e10c9b1f46d188ae8eebd75cd9c62ec9ad06f8d', + ], + takingAmount: '11.904758', // Shares received for $10 investment + makingAmount: '9.999996', + }, + }; + } catch { + // Fallback response if parsing fails - still track the addresses + const userAddress = USER_WALLET_ADDRESS.toLowerCase(); + const proxyAddress = PROXY_WALLET_ADDRESS.toLowerCase(); + orderSubmitted.add(userAddress); + orderSubmitted.add(proxyAddress); + // Note: Can't check tokenId in catch block, so don't add to celticsOrderSubmitted + + return { + statusCode: 200, + json: { + success: true, + errorMsg: '', + status: 'matched', + orderID: + '0x3bd7640f8ec62a31ab9f95f0b94582d3a7fb159dbaed773eb5fcca45c43bcdb9', + transactionsHashes: [ + '0x6a14089acbb670682a700ba57e10c9b1f46d188ae8eebd75cd9c62ec9ad06f8d', + ], + takingAmount: '11.904758', + makingAmount: '9.999996', + }, + }; + } + }); + + // Mock CLOB API order endpoint (called after relayer endpoint) + // This handles both POST /order and POST /book?token_id=... endpoints + // Higher priority to ensure it catches order requests before the broad cash-out CLOB mock + await mockServer + .forPost('/proxy') + .matching((request) => { + const urlParam = new URL(request.url).searchParams.get('url'); + return Boolean( + urlParam && + (urlParam.includes('clob.polymarket.com/order') || + (urlParam.includes('clob.polymarket.com/book') && + urlParam.includes('token_id='))), + ); + }) + .asPriority(PRIORITY.API_OVERRIDE + 2) // Higher priority than cash-out CLOB mock + .thenCallback(async (request) => { + try { + const bodyText = await request.body.getText(); + const body = bodyText ? JSON.parse(bodyText) : {}; + const order = body?.order; + + // Check if it's a BUY order (for opening positions) + const isBuyOrder = order?.side === 'BUY'; + const isCelticsToken = + order?.tokenId === + '51851880223290407825872150827934296608070009371891114025629582819868766043137'; + + if (isBuyOrder) { + const userAddress = + order?.signer?.toLowerCase() || USER_WALLET_ADDRESS.toLowerCase(); + const proxyAddress = + order?.maker?.toLowerCase() || PROXY_WALLET_ADDRESS.toLowerCase(); + + // Only track Celtics vs Nets orders for positions/activity + if (isCelticsToken) { + orderSubmitted.add(userAddress); + orderSubmitted.add(proxyAddress); + celticsOrderSubmitted.add(userAddress); + celticsOrderSubmitted.add(proxyAddress); + } + + // Return success for any BUY order + // Use the amounts from the order if available, otherwise use defaults + const makingAmount = order?.makerAmount + ? (parseInt(order.makerAmount, 10) / 1000000).toString() + : '9.999996'; + const takingAmount = order?.takerAmount + ? (parseInt(order.takerAmount, 10) / 1000000).toString() + : '11.904758'; + + return { + statusCode: 200, + json: { + errorMsg: '', + orderID: + '0x3bd7640f8ec62a31ab9f95f0b94582d3a7fb159dbaed773eb5fcca45c43bcdb9', + takingAmount, // Shares received + makingAmount, // Amount spent + status: 'matched', + transactionsHashes: [ + '0x6a14089acbb670682a700ba57e10c9b1f46d188ae8eebd75cd9c62ec9ad06f8d', + ], + success: true, + }, + }; + } + + // For non-BUY orders, let other mocks handle them + return { + statusCode: 200, + json: { + success: false, + errorMsg: 'Order not matched', + }, + }; + } catch { + return { + statusCode: 200, + json: { + success: false, + errorMsg: 'Invalid request', + }, + }; + } + }); + await POLYMARKET_ADD_CELTICS_POSITION_MOCKS(mockServer); + await POLYMARKET_ADD_CELTICS_ACTIVITY_MOCKS(mockServer); + + // Update balance after opening position + // await POLYMARKET_UPDATE_USDC_BALANCE_MOCKS(mockServer, 'open-position'); +}; + /** * Dedicated mock for loading USDC balance specifically for withdraw flow * This ensures balance refresh for withdraw/deposit flows doesn't interfere with cash-out @@ -1141,9 +1583,8 @@ export const POLYMARKET_REMOVE_CLAIMED_POSITIONS_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/positions\?.*user=0x[a-fA-F0-9]{40}.*$/.test( - url, - ) && + url.includes('data-api.polymarket.com/positions') && + url.includes('user=0x') && url.includes('redeemable=true'), ); }) @@ -1170,9 +1611,8 @@ export const POLYMARKET_ADD_CLAIMED_POSITIONS_TO_ACTIVITY_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/activity\?.*user=0x[a-fA-F0-9]{40}/.test( - url, - ), + url.includes('data-api.polymarket.com/activity') && + url.includes('user=0x'), ); }) .asPriority(PRIORITY.API_OVERRIDE) // Higher priority to override the original activity mock @@ -1226,9 +1666,8 @@ export const POLYMARKET_REMOVE_CASHED_OUT_POSITION_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/positions\?.*user=0x[a-fA-F0-9]{40}.*$/.test( - url, - ) && + url.includes('data-api.polymarket.com/positions') && + url.includes('user=0x') && !url.includes('redeemable=true'), ); }) @@ -1274,9 +1713,8 @@ export const POLYMARKET_REMOVE_CASHED_OUT_POSITION_MOCKS = async ( const url = new URL(request.url).searchParams.get('url'); return Boolean( url && - /^https:\/\/data-api\.polymarket\.com\/activity\?.*user=0x[a-fA-F0-9]{40}/.test( - url, - ), + url.includes('data-api.polymarket.com/activity') && + url.includes('user=0x'), ); }) .asPriority(PRIORITY.API_OVERRIDE) // Higher priority to override the original activity mock diff --git a/e2e/api-mocking/mock-responses/polymarket/polymarket-order-book-response.ts b/e2e/api-mocking/mock-responses/polymarket/polymarket-order-book-response.ts index 9d69a95235bc..05c830fc32b6 100644 --- a/e2e/api-mocking/mock-responses/polymarket/polymarket-order-book-response.ts +++ b/e2e/api-mocking/mock-responses/polymarket/polymarket-order-book-response.ts @@ -158,3 +158,27 @@ export const POLYMARKET_PELICANS_ORDER_BOOK_RESPONSE = { { price: '0.43', size: '2500' }, ], }; + +// Celtics vs Nets order book (Celtics token) +// Market condition ID: 0x81daa857b8fa34cd3627c8cdbe5d92ea98756bcbe1e5cfcfffb94754e4d5ed86 +// All asks at 0.84 to ensure consistent price regardless of order size +export const POLYMARKET_CELTICS_ORDER_BOOK_RESPONSE = { + market: '0x81daa857b8fa34cd3627c8cdbe5d92ea98756bcbe1e5cfcfffb94754e4d5ed86', + asset_id: + '51851880223290407825872150827934296608070009371891114025629582819868766043137', + timestamp: '1761177638154', + hash: '97f541df6e9baa53e3583f8ebe69d06e86a2198c', + bids: [ + { price: '0.83', size: '10000' }, // Best bid for selling + { price: '0.82', size: '5000' }, + { price: '0.81', size: '3000' }, + { price: '0.80', size: '2000' }, + ], + asks: [ + { price: '0.84', size: '1000000' }, // Single price level with massive liquidity - enough for any reasonable order size + // No higher-priced asks to prevent price increases for larger orders + ], + min_order_size: '5', + tick_size: '0.01', + neg_risk: false, +}; diff --git a/e2e/api-mocking/mock-responses/polymarket/polymarket-positions-response.ts b/e2e/api-mocking/mock-responses/polymarket/polymarket-positions-response.ts index 3ac1042a036f..7befc57ba4af 100644 --- a/e2e/api-mocking/mock-responses/polymarket/polymarket-positions-response.ts +++ b/e2e/api-mocking/mock-responses/polymarket/polymarket-positions-response.ts @@ -162,6 +162,40 @@ export const POLYMARKET_CURRENT_POSITIONS_RESPONSE = [ negativeRisk: true, }, ]; + +export const POLYMARKET_NEW_OPEN_POSITION_CELTICS_NETS_RESPONSE = [ + { + proxyWallet: PROXY_WALLET_ADDRESS, + asset: + '51851880223290407825872150827934296608070009371891114025629582819868766043137', + conditionId: + '0x81daa857b8fa34cd3627c8cdbe5d92ea98756bcbe1e5cfcfffb94754e4d5ed86', + size: 11.904758, + avgPrice: 0.83, + initialValue: 10, + currentValue: 10.7142822, + cashPnl: 0.83333306, + percentPnl: 8.433734939, + totalBought: 11.904758, + realizedPnl: 0, + percentRealizedPnl: 0, + curPrice: 0.9, + redeemable: false, + mergeable: false, + title: 'Celtics vs. Nets', + slug: 'nba-bos-bkn-2025-11-18', + icon: 'https://polymarket-upload.s3.us-east-2.amazonaws.com/super+cool+basketball+in+red+and+blue+wow.png', + eventId: '79682', + eventSlug: 'nba-bos-bkn-2025-11-18', + outcome: 'Celtics', + outcomeIndex: 0, + oppositeOutcome: 'Nets', + oppositeAsset: + '51090123154876409384652748958994213129207000557350215937559106819875795938227', + endDate: '2025-11-19', + negativeRisk: false, + }, +]; /* *endpoint: /positions?user&redeemable=true This contains all lost positions in resolved markets (no winning positions) diff --git a/e2e/pages/Predict/PredictDetailsPage.ts b/e2e/pages/Predict/PredictDetailsPage.ts index 62dc405280d9..681795bfea76 100644 --- a/e2e/pages/Predict/PredictDetailsPage.ts +++ b/e2e/pages/Predict/PredictDetailsPage.ts @@ -1,6 +1,7 @@ import { Matchers, Gestures } from '../../framework'; import { PredictBalanceSelectorsIDs, + PredictBuyPreviewSelectorsIDs, PredictMarketDetailsSelectorsIDs, PredictMarketDetailsSelectorsText, } from '../../selectors/Predict/Predict.selectors'; @@ -37,6 +38,12 @@ class PredictDetailsPage { return Matchers.getElementByID(PredictBalanceSelectorsIDs.BALANCE_CARD); } + get placeBetButton(): DetoxElement { + return Matchers.getElementByID( + PredictBuyPreviewSelectorsIDs.PLACE_BET_BUTTON, + ); + } + async tapBackButton(): Promise { await Gestures.waitAndTap(this.backButton, { elemDescription: 'Back button', @@ -63,6 +70,56 @@ class PredictDetailsPage { elemDescription: 'Cash out button', }); } + + async tapOpenPositionValue(): Promise { + // Use regex to match both "Celtics\n83¢" and "Celtics • 83¢" formats + const celticsButton = (await Matchers.getElementByText( + /Celtics[\s•\n]*83¢/, + )) as unknown as DetoxElement; + + await Gestures.waitAndTap(celticsButton, { + elemDescription: 'Celtics outcome button', + }); + } + + async tapPositionAmount(amount: string): Promise { + const digits = amount.split(''); + + for (const digit of digits) { + const digitElement = (await Matchers.getElementByText( + digit, + )) as unknown as DetoxElement; + await Gestures.waitAndTap(digitElement, { + elemDescription: `tap ${digit} on keypad`, + }); + } + } + + async tapDoneButton(): Promise { + const continueButton = (await Matchers.getElementByText( + 'Done', + )) as unknown as DetoxElement; + + await Gestures.waitAndTap(continueButton, { + elemDescription: 'Done button', + }); + } + + async tapContinueButton(): Promise { + const continueButton = (await Matchers.getElementByText( + 'Continue', + )) as unknown as DetoxElement; + + await Gestures.waitAndTap(continueButton, { + elemDescription: 'Continue button', + }); + } + + async tapOpenPosition(): Promise { + await Gestures.waitAndTap(this.placeBetButton, { + elemDescription: 'Place bet button', + }); + } } export default new PredictDetailsPage(); diff --git a/e2e/pages/Predict/PredictMarketList.ts b/e2e/pages/Predict/PredictMarketList.ts index c1251ff23870..da85b5d23257 100644 --- a/e2e/pages/Predict/PredictMarketList.ts +++ b/e2e/pages/Predict/PredictMarketList.ts @@ -19,6 +19,9 @@ class PredictMarketList { get categoryTabs(): DetoxElement { return Matchers.getElementByID(PredictMarketListSelectorsIDs.CATEGORY_TABS); } + get backButton(): DetoxElement { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.BACK_BUTTON); + } getMarketCard(category: CategoryTab, cardIndex: number): DetoxElement { return Matchers.getElementByID( @@ -93,6 +96,12 @@ class PredictMarketList { elemDescription: `Tap No in ${category} feed index ${cardIndex}`, }); } + + async tapBackButton(): Promise { + await Gestures.waitAndTap(this.backButton, { + elemDescription: 'Tap Back button on market feed', + }); + } } export default new PredictMarketList(); diff --git a/e2e/selectors/Predict/Predict.selectors.ts b/e2e/selectors/Predict/Predict.selectors.ts index 3916ee8cd6a6..f3bd60b363f1 100644 --- a/e2e/selectors/Predict/Predict.selectors.ts +++ b/e2e/selectors/Predict/Predict.selectors.ts @@ -30,7 +30,7 @@ export const PredictMarketListSelectorsIDs = { SPORTS_TAB: 'predict-market-list-sports-tab', CRYPTO_TAB: 'predict-market-list-crypto-tab', POLITICS_TAB: 'predict-market-list-politics-tab', - + BACK_BUTTON: 'back-button', // Empty state EMPTY_STATE: 'predict-market-list-empty-state', } as const; @@ -107,6 +107,15 @@ export const PredictPositionSelectorsIDs = { RESOLVED_POSITION_CARD: 'predict-resolved-position-card', } as const; +// ======================================== +// PREDICT BUY PREVIEW SELECTORS +// ======================================== + +export const PredictBuyPreviewSelectorsIDs = { + // Buy/Place bet button + PLACE_BET_BUTTON: 'predict-buy-preview-place-bet-button', +} as const; + // ======================================== // PREDICT CASH OUT SELECTORS // ======================================== diff --git a/e2e/specs/predict/predict-open-position.spec.ts b/e2e/specs/predict/predict-open-position.spec.ts new file mode 100644 index 000000000000..59a37382e672 --- /dev/null +++ b/e2e/specs/predict/predict-open-position.spec.ts @@ -0,0 +1,117 @@ +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { SmokePredictions } from '../../tags'; +import { loginToApp } from '../../viewHelper'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; +import PredictMarketList from '../../pages/Predict/PredictMarketList'; +import PredictDetailsPage from '../../pages/Predict/PredictDetailsPage'; +import Assertions from '../../framework/Assertions'; +import WalletView from '../../pages/wallet/WalletView'; +import { remoteFeatureFlagPredictEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import { Mockttp } from 'mockttp'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { + POLYMARKET_COMPLETE_MOCKS, + POLYMARKET_POSITIONS_WITH_WINNINGS_MOCKS, + POLYMARKET_POST_OPEN_POSITION_MOCKS, + POLYMARKET_UPDATE_USDC_BALANCE_MOCKS, +} from '../../api-mocking/mock-responses/polymarket/polymarket-mocks'; +import ActivitiesView from '../../pages/Transactions/ActivitiesView'; + +/* +Test Scenario: Open position on Celtics vs. Nets market + Verifies the open position flow for a predictions market: + 1. Navigate to Predictions tab and open market list + 2. Select Celtics vs. Nets market from sports category + 3. Open a position with $10 investment + 4. Verify position appears in Positions tab + 5. Verify balance updates to $17.76 + 6. Verify position appears in Activities tab +*/ +const positionDetails = { + name: 'Celtics vs. Nets', + positionAmount: '10', + newBalance: '$17.76', + category: 'sports' as const, + marketIndex: 1, +}; + +const PredictionMarketFeature = async (mockServer: Mockttp) => { + await setupRemoteFeatureFlagsMock( + mockServer, + remoteFeatureFlagPredictEnabled(true), + ); + await POLYMARKET_COMPLETE_MOCKS(mockServer); + await POLYMARKET_POSITIONS_WITH_WINNINGS_MOCKS(mockServer, false); // do not include winnings. Claim Button is animated and problematic for e2e +}; + +describe(SmokePredictions('Predictions'), () => { + it('opens position on Celtics vs. Nets market', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().withPolygon().build(), + restartDevice: true, + testSpecificMock: PredictionMarketFeature, + }, + async ({ mockServer }) => { + await loginToApp(); + + await WalletView.tapOnPredictionsTab(); + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapPredictButton(); + await device.disableSynchronization(); + + await Assertions.expectElementToBeVisible(PredictMarketList.container, { + description: 'Predict market list container should be visible', + }); + + await PredictMarketList.tapCategoryTab(positionDetails.category); + await PredictMarketList.tapMarketCard( + positionDetails.category, + positionDetails.marketIndex, + ); + await PredictDetailsPage.tapOpenPositionValue(); + + await POLYMARKET_POST_OPEN_POSITION_MOCKS(mockServer); + await POLYMARKET_UPDATE_USDC_BALANCE_MOCKS(mockServer, 'open-position'); + + await PredictDetailsPage.tapPositionAmount( + positionDetails.positionAmount, + ); + await PredictDetailsPage.tapDoneButton(); + + await PredictDetailsPage.tapOpenPosition(); + await device.enableSynchronization(); + + await Assertions.expectElementToBeVisible( + PredictDetailsPage.positionsTab, + { + description: + 'Position tab should appear after opening a new position', + }, + ); + + await Assertions.expectTextDisplayed(positionDetails.name, { + description: 'Position card for Celtics vs. Nets should appear', + }); + + await PredictDetailsPage.tapBackButton(); + await Assertions.expectTextDisplayed(positionDetails.newBalance, { + description: `USDC balance should display ${positionDetails.newBalance} after opening position`, + }); + await PredictMarketList.tapBackButton(); + + // Verify position appears in current positions list on homepage + + await Assertions.expectTextDisplayed(positionDetails.name, { + description: `Position card should have text "${positionDetails.name}"`, + }); + + await TabBarComponent.tapActivity(); + await ActivitiesView.tapOnPredictionsTab(); + await ActivitiesView.tapPredictPosition(positionDetails.name); + }, + ); + }); +}); diff --git a/e2e/specs/predict/predict-select-bet.spec.ts b/e2e/specs/predict/predict-select-bet.spec.ts deleted file mode 100644 index 03b6d026eb80..000000000000 --- a/e2e/specs/predict/predict-select-bet.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { SmokePredictions } from '../../tags'; -import { loginToApp } from '../../viewHelper'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; -import PredictMarketList from '../../pages/Predict/PredictMarketList'; -import PredictDetailsPage from '../../pages/Predict/PredictDetailsPage'; -import Assertions from '../../framework/Assertions'; - -import { remoteFeatureFlagPredictEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; -import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; - -const PredictionMarketFeature = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock( - mockServer, - remoteFeatureFlagPredictEnabled(true), - ); -}; - -describe(SmokePredictions('Predictions'), () => { - it('should open predict tab and view market details', async () => { - await withFixtures( - { - fixture: new FixtureBuilder().build(), - restartDevice: true, - testSpecificMock: PredictionMarketFeature, - }, - async () => { - await loginToApp(); - - // Navigate to actions - await TabBarComponent.tapActions(); - - await WalletActionsBottomSheet.tapPredictButton(); - - await Assertions.expectElementToBeVisible(PredictMarketList.container, { - description: 'Predict market list container should be visible', - }); - await PredictMarketList.tapCategoryTab('new'); - await PredictMarketList.tapMarketCard('new', 1); - await Assertions.expectElementToBeVisible( - PredictDetailsPage.container, - { - description: 'Predict details page container should be visible', - }, - ); - }, - ); - }); -});