diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index dfbefc83ed4..7d2cd1f5da9 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -66,10 +66,18 @@ body: id: version attributes: label: Version - description: What version of MetaMask are you running? You can find the version in "Settings" > "About" + description: What version of MetaMask are you running? You can find the version in "Settings" > "About MetaMask" placeholder: "7.50.0" validations: required: true + - type: input + id: build-number + attributes: + label: Build number + description: What build number of MetaMask are you running? You can find the build number in "Settings" > "About MetaMask" + placeholder: "3055" + validations: + required: true - type: dropdown id: build attributes: diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 8cba763d005..d3eb7d0bfdb 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -33,6 +33,7 @@ jobs: build-android-apks: name: Build Android E2E APKs runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg + timeout-minutes: 40 env: GRADLE_USER_HOME: /home/admin/_work/.gradle CACHE_GENERATION: v1 # Increment this to bust the cache (v1, v2, v3, etc.) diff --git a/.github/workflows/needs-e2e-build.yml b/.github/workflows/needs-e2e-build.yml index 38921e1c489..b65e7491a63 100644 --- a/.github/workflows/needs-e2e-build.yml +++ b/.github/workflows/needs-e2e-build.yml @@ -26,7 +26,7 @@ on: jobs: needs-e2e-build: name: Check if builds will happen - runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-md + runs-on: ubuntu-latest outputs: android: ${{ steps.set-outputs.outputs.android_final }} ios: ${{ steps.set-outputs.outputs.ios_final }} diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml new file mode 100644 index 00000000000..dfc417614bc --- /dev/null +++ b/.github/workflows/push-eas-update.yml @@ -0,0 +1,273 @@ +name: Push OTA Update (Test) + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'Pull request number to publish' + required: true + type: string + base_branch: + description: 'Base branch ref to compare fingerprints against (e.g., main)' + required: true + type: string + +permissions: + contents: read + id-token: write + +env: + TARGET_PR_NUMBER: ${{ inputs.pr_number }} + BASE_BRANCH_REF: ${{ inputs.base_branch }} + +jobs: + fingerprint-comparison: + name: Compare Expo Fingerprints + runs-on: ubuntu-latest + outputs: + branch_fingerprint: ${{ steps.branch_fingerprint.outputs.fingerprint }} + main_fingerprint: ${{ steps.main_fingerprint.outputs.fingerprint }} + fingerprints_equal: ${{ steps.compare.outputs.equal }} + steps: + - name: Checkout target PR branch + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ env.TARGET_PR_NUMBER }}/head + fetch-depth: 0 + + - name: Checkout base branch snapshot + uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_BRANCH_REF }} + path: main + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install dependencies (workflow branch) + run: | + echo "đŸ“Ļ Installing dependencies for current branch..." + yarn install --immutable + + - name: Generate fingerprint (workflow branch) + id: branch_fingerprint + run: | + echo "đŸ§Ŧ Generating fingerprint for current branch..." + FINGERPRINT=$(yarn fingerprint:generate) + echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" + echo "Target PR fingerprint: $FINGERPRINT" + echo "Writing detailed fingerprint file to fingerprint-pr.json" + npx @expo/fingerprint ./ > fingerprint-pr.json + + - name: Install dependencies (base branch) + working-directory: main + run: | + echo "đŸ“Ļ Installing dependencies for base branch snapshot (${BASE_BRANCH_REF})..." + yarn install --immutable + + - name: Generate fingerprint (base branch) + id: main_fingerprint + working-directory: main + run: | + echo "đŸ§Ŧ Generating fingerprint for base branch (${BASE_BRANCH_REF})..." + FINGERPRINT=$(yarn fingerprint:generate) + echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" + echo "Base branch fingerprint: $FINGERPRINT" + echo "Writing detailed fingerprint file to ../fingerprint-base.json" + npx @expo/fingerprint ./ > ../fingerprint-base.json + + - name: Compare fingerprints + id: compare + env: + BRANCH_FP: ${{ steps.branch_fingerprint.outputs.fingerprint }} + MAIN_FP: ${{ steps.main_fingerprint.outputs.fingerprint }} + run: | + if [ -z "$BRANCH_FP" ] || [ -z "$MAIN_FP" ]; then + echo "❌ Fingerprint generation failed." >&2 + exit 1 + fi + + echo "Target PR fingerprint: $BRANCH_FP" + echo "Base branch fingerprint: $MAIN_FP" + + if [ "$BRANCH_FP" = "$MAIN_FP" ]; then + echo "✅ Fingerprints match. No native changes detected." + echo "equal=true" >> "$GITHUB_OUTPUT" + else + echo "âš ī¸ Fingerprints differ. Native changes detected." + echo "equal=false" >> "$GITHUB_OUTPUT" + if [ -f fingerprint-base.json ] && [ -f fingerprint-pr.json ]; then + echo "Fingerprint differences:" + npx @expo/fingerprint ./ fingerprint-base.json fingerprint-pr.json || true + else + echo "Detailed fingerprint files not found; skipping diff." + fi + fi + + - name: Record fingerprint summary + env: + BRANCH_FP: ${{ steps.branch_fingerprint.outputs.fingerprint }} + MAIN_FP: ${{ steps.main_fingerprint.outputs.fingerprint }} + MATCHES: ${{ steps.compare.outputs.equal }} + TARGET_PR_NUMBER: ${{ env.TARGET_PR_NUMBER }} + BASE_BRANCH_REF: ${{ env.BASE_BRANCH_REF }} + run: | + { + echo "### Expo Fingerprint Comparison" + echo "" + echo "- Target PR (#$TARGET_PR_NUMBER) fingerprint: \`$BRANCH_FP\`" + echo "- Base branch (\`$BASE_BRANCH_REF\`) fingerprint: \`$MAIN_FP\`" + echo "- Match: \`$MATCHES\`" + } >> "$GITHUB_STEP_SUMMARY" + + approval: + name: Require OTA Update Approval + needs: fingerprint-comparison + if: ${{ needs.fingerprint-comparison.outputs.fingerprints_equal == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Await approval from mobile platform team + uses: op5dev/require-team-approval@dfd7b8b9a88bf82a955c103f7e19642b0411aecd + with: + team: mobile-platform + pr-number: ${{ env.TARGET_PR_NUMBER }} + token: ${{ secrets.METAMASK_MOBILE_ORG_READ_TOKEN }} + + push-update: + name: Push EAS Update + runs-on: ubuntu-latest + environment: expo-update + needs: + - fingerprint-comparison + - approval + if: ${{ needs.fingerprint-comparison.outputs.fingerprints_equal == 'true' }} + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_PROJECT_ID: ${{ secrets.EXPO_PROJECT_ID }} + EXPO_CHANNEL: ${{ vars.EXPO_CHANNEL }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ env.TARGET_PR_NUMBER }}/head + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install dependencies + run: | + echo "đŸ“Ļ Installing dependencies..." + yarn install --immutable + + - name: Setup project + run: | + echo "🔧 Running setup for GitHub CI..." + yarn setup:github-ci + + - name: Display configuration + run: | + TARGET_RUNTIME_VERSION=$(node -p "require('./package.json').version") + TARGET_PROJECT_ID=$(node -p "require('./ota.config.js').PROJECT_ID") + echo "🔧 Configuration:" + echo " Channel: ${EXPO_CHANNEL:-}" + echo " Channel (vars.EXPO_CHANNEL): ${{ vars.EXPO_CHANNEL }}" + echo " Message: test eas update workflow" + echo " Runtime Version (target): ${TARGET_RUNTIME_VERSION}" + # Fingerprint comparison temporarily disabled + echo "" + echo "📱 Project Info:" + echo " Project ID (target branch): ${TARGET_PROJECT_ID}" + echo " EXPO_PROJECT_ID (from secrets): $EXPO_PROJECT_ID" + echo " EXPO_TOKEN (from secrets): $EXPO_TOKEN" + + - name: Prepare Expo update signing key + env: + EXPO_KEY_PRIV: ${{ secrets.EXPO_KEY_PRIV }} + run: | + if [ -z "${EXPO_KEY_PRIV}" ]; then + echo "::error title=Missing EXPO_KEY_PRIV::EXPO_KEY_PRIV secret is not configured. Cannot sign update." >&2 + exit 1 + fi + mkdir -p keys + echo "Writing Expo private key to ./keys/private-key.pem" + printf '%s' "${EXPO_KEY_PRIV}" > keys/private-key.pem + + - name: Push EAS Update + env: + # Skip linting during Metro transform in CI (linting already done separately) + SKIP_TRANSFORM_LINT: 'true' + # Increase Node heap to avoid OOM during Expo export in CI + NODE_OPTIONS: '--max_old_space_size=8192' + # Disable LavaMoat sandbox to prevent duplicate bundle executions in CI + EXPO_NO_LAVAMOAT: '1' + run: | + echo "🚀 Publishing EAS update..." + + if [ -z "${EXPO_CHANNEL}" ]; then + echo "::error title=Missing EXPO_CHANNEL::EXPO_CHANNEL environment variable is not set. Cannot publish update." >&2 + exit 1 + fi + + if [ ! -f keys/private-key.pem ]; then + echo "::error title=Missing signing key::keys/private-key.pem not found. Ensure the signing key step ran successfully." >&2 + exit 1 + fi + + echo "â„šī¸ Git head: $(git rev-parse HEAD)" + echo "â„šī¸ Checking for eas script in package.json..." + if ! grep -q '"eas": "eas"' package.json; then + echo "::error title=Missing eas script::package.json does not include an \"eas\" script. Commit hash: $(git rev-parse HEAD)." >&2 + exit 1 + fi + + echo "â„šī¸ Available yarn scripts containing eas:" + yarn run --json | grep '"name":"eas"' || true + + yarn run eas update \ + --channel "${EXPO_CHANNEL}" \ + --private-key-path "./keys/private-key.pem" \ + --message "test eas update workflow" \ + --non-interactive + + - name: Update summary + if: success() + run: | + { + echo "### ✅ EAS Update Published Successfully" + echo + echo "**Channel:** \`${EXPO_CHANNEL:-}\`" + echo "**Message:** test eas update workflow" + echo + echo "Users on the \`${EXPO_CHANNEL:-}\` channel will receive this update on their next app launch." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Update summary on failure + if: failure() + run: | + { + echo "### ❌ EAS Update Failed" + echo + echo "Check the logs above for error details." + } >> "$GITHUB_STEP_SUMMARY" + + fingerprint-mismatch: + name: Fingerprint Mismatch Guard + needs: fingerprint-comparison + if: ${{ needs.fingerprint-comparison.outputs.fingerprints_equal != 'true' }} + runs-on: ubuntu-latest + steps: + - name: Fail on native changes + run: | + echo "::error title=Fingerprint mismatch::Current branch fingerprint differs from main. Native changes detected; aborting workflow." + echo "Current fingerprint: ${{ needs.fingerprint-comparison.outputs.branch_fingerprint }}" + echo "Main fingerprint: ${{ needs.fingerprint-comparison.outputs.main_fingerprint }}" + exit 1 diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 2d3cdd8bb27..7bbaf3d9bc0 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -65,5 +65,5 @@ jobs: - name: Upload all test artifacts (XMLs + Screenshots) uses: actions/upload-artifact@v4 with: - name: e2e-smoke-android-all-test-artifacts + name: e2e-smoke-android-flask-all-test-artifacts path: all-test-artifacts/ diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 360a619d1b5..c4b65c57dff 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -28,6 +28,7 @@ import AddAsset from '../../Views/AddAsset'; import Collectible from '../../Views/Collectible'; import NftFullView from '../../Views/NftFullView'; import TokensFullView from '../../Views/TokensFullView'; +import TrendingTokensFullView from '../../Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView'; import SendLegacy from '../../Views/confirmations/legacy/Send'; import SendTo from '../../Views/confirmations/legacy/SendFlow/SendTo'; import { RevealPrivateCredential } from '../../Views/RevealPrivateCredential'; @@ -920,7 +921,6 @@ const MainNavigator = () => { const perpsEnabledFlag = useFeatureFlag( FeatureFlagNames.perpsPerpTradingEnabled, ); - const isEvmSelected = useSelector(selectIsEvmNetworkSelected); const isPerpsEnabled = useMemo(() => perpsEnabledFlag, [perpsEnabledFlag]); // Get feature flag state for conditional Predict screen registration const predictEnabledFlag = useFeatureFlag( @@ -933,6 +933,9 @@ const MainNavigator = () => { const { enabled: isSendRedesignEnabled } = useSelector( selectSendRedesignFlags, ); + const isAssetsTrendingTokensEnabled = useSelector( + selectAssetsTrendingTokensEnabled, + ); return ( { }} /> + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> + { [chainId]: true, }); } - if ( - isRemoveGlobalNetworkSelectorEnabled() && - enabledEVMNetworks.length === 0 - ) { + if (enabledEVMNetworks.length === 0) { selectNetwork(chainId); } } diff --git a/app/components/UI/AssetOverview/AssetOverview.test.tsx b/app/components/UI/AssetOverview/AssetOverview.test.tsx index e1c5e53f156..0c05102d734 100644 --- a/app/components/UI/AssetOverview/AssetOverview.test.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.test.tsx @@ -332,18 +332,6 @@ describe('AssetOverview', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should render correctly', async () => { - const container = renderWithProvider( - , - { state: mockInitialState }, - ); - expect(container).toMatchSnapshot(); - }); it('should handle buy button press', async () => { const { getByTestId } = renderWithProvider( @@ -811,21 +799,49 @@ describe('AssetOverview', () => { expect(buyButton).toBeNull(); }); - it('should render native balances even if there are no accounts for the asset chain in the state', async () => { - const container = renderWithProvider( - , - { state: mockInitialState }, + it('renders native balances when no accounts exist for asset chain', () => { + // Create state without accounts for chain 0x2 + const stateWithoutChainAccounts = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + AccountTrackerController: { + accountsByChainId: { + // Only has accounts for chain 0x1, not 0x2 + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_2]: { balance: '0x1' }, + }, + }, + }, + }, + }, + }; + + const nativeAsset = { + ...asset, + chainId: '0x2', + isNative: true, + }; + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: stateWithoutChainAccounts }, ); - expect(container).toMatchSnapshot(); + // Component should render without crashing + const container = getByTestId(TokenOverviewSelectorsIDs.CONTAINER); + expect(container).toBeDefined(); + + // When no accounts exist for the chain, renderFromWei(undefined) returns '0' + // Balance component should render because balance is '0' (not null/undefined) + // Verify secondaryBalance shows '0' with the ticker + const secondaryBalance = queryByTestId(TOKEN_AMOUNT_BALANCE_TEST_ID); + if (secondaryBalance) { + expect(secondaryBalance.props.children).toContain('0'); + expect(secondaryBalance.props.children).toContain(nativeAsset.symbol); + } }); it('should render native balances when non evm network is selected', async () => { @@ -1074,7 +1090,7 @@ describe('AssetOverview', () => { const secondaryBalance = getByTestId(TOKEN_AMOUNT_BALANCE_TEST_ID); expect(mainBalance.props.children).toBe('1500'); - expect(secondaryBalance.props.children).toBe('0 ETH'); + expect(secondaryBalance.props.children).toBe('400 ETH'); }); it('should handle multichain send for Solana assets', async () => { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index d9b67da8aa2..40a0d77b343 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -36,6 +36,8 @@ import { renderFromTokenMinimalUnit, renderFromWei, toHexadecimal, + addCurrencySymbol, + balanceToFiatNumber, } from '../../../util/number'; import { getEther } from '../../../util/transactions'; import Text from '../../Base/Text'; @@ -489,7 +491,8 @@ const AssetOverview: React.FC = ({ : undefined; ///: END:ONLY_INCLUDE_IF - if (isMultichainAccountsState2Enabled) { + if (isMultichainAccountsState2Enabled && asset.balance != null) { + // When state2 is enabled and asset has balance, use it directly balance = asset.balance; } else if (isMultichainAsset) { balance = asset.balance @@ -515,20 +518,15 @@ const AssetOverview: React.FC = ({ if ( !isEvmAccountType(selectedInternalAccount?.type as KeyringAccountType) ) { - balance = asset.balance || 0; + balance = asset.balance ?? undefined; } else { balance = itemAddress && tokenBalanceHex ? renderFromTokenMinimalUnit(tokenBalanceHex, asset.decimals) - : 0; + : (asset.balance ?? undefined); } } - const mainBalance = asset.balanceFiat || ''; - const secondaryBalance = `${balance} ${ - asset.isETH ? asset.ticker : asset.symbol - }`; - const convertedMultichainAssetRates = isNonEvmAsset && multichainAssetRates ? { @@ -563,6 +561,53 @@ const AssetOverview: React.FC = ({ comparePrice = calculatedComparePrice; } + // Calculate fiat balance if not provided in asset (e.g., when coming from trending view) + let mainBalance = asset.balanceFiat || ''; + if (!mainBalance && balance != null) { + // Convert balance to number for calculations + const balanceNumber = + typeof balance === 'number' ? balance : parseFloat(String(balance)); + + if (balanceNumber > 0 && !isNaN(balanceNumber)) { + if (isNonEvmAsset && multichainAssetRates?.rate) { + // For non-EVM assets, use multichainAssetRates directly + const rate = Number(multichainAssetRates.rate); + const balanceFiatNumber = balanceNumber * rate; + mainBalance = + balanceFiatNumber >= 0.01 || balanceFiatNumber === 0 + ? addCurrencySymbol(balanceFiatNumber, currentCurrency) + : `< ${addCurrencySymbol('0.01', currentCurrency)}`; + } else if (!isNonEvmAsset) { + // For EVM assets, calculate fiat balance directly using balance, market price, and conversion rate + const tickerConversionRate = + conversionRateByTicker?.[nativeCurrency]?.conversionRate; + + if ( + tickerConversionRate && + marketDataRate !== undefined && + isFinite(marketDataRate) + ) { + const balanceFiatNumber = balanceToFiatNumber( + balanceNumber, + tickerConversionRate, + marketDataRate, + ); + if (isFinite(balanceFiatNumber)) { + mainBalance = + balanceFiatNumber >= 0.01 || balanceFiatNumber === 0 + ? addCurrencySymbol(balanceFiatNumber, currentCurrency) + : `< ${addCurrencySymbol('0.01', currentCurrency)}`; + } + } + } + } + } + + const secondaryBalance = + balance != null + ? `${balance} ${asset.isETH ? asset.ticker : asset.symbol}` + : undefined; + return ( {asset.hasBalanceError ? ( diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx index a29a40147de..5e7554f8a6e 100644 --- a/app/components/UI/AssetOverview/Balance/Balance.tsx +++ b/app/components/UI/AssetOverview/Balance/Balance.tsx @@ -123,6 +123,13 @@ const Balance = ({ ); const allMultichainAssetsRates = useSelector(selectMultichainAssetsRates); const getPricePercentChange1d = () => { + // First check if asset has pricePercentChange1d from navigation params (e.g., from trending view) + if ( + asset?.pricePercentChange1d !== undefined && + asset?.pricePercentChange1d !== null + ) { + return asset.pricePercentChange1d; + } if (isEvmNetworkSelected) { return evmPricePercentChange1d; } diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx index 07ba0eb23ba..98703f649f0 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx @@ -306,6 +306,10 @@ describe('TokenDetails', () => { mockTokenMarketDataByChainId['0x1'][ '0x6B175474E89094C44Da98b954EedeAC495271d0F' ], + // null metadata ensures: + // 1. tokenList is null (no aggregators array in metadata) + // 2. tokenMetadata is null (so tokenMetadata condition is false) + metadata: null, }, selectConversionRateBySymbol: mockExchangeRate, selectNativeCurrencyByChainId: 'ETH', @@ -337,11 +341,15 @@ describe('TokenDetails', () => { } }); - const { getByText, queryByText, debug } = renderWithProvider( - , + const tokenWithoutAddress = { + ...mockDAI, + address: '', // Empty address makes contractAddress null + }; + + const { getByText, queryByText } = renderWithProvider( + , { state: initialState }, ); - debug(); expect(queryByText('Token details')).toBeNull(); expect(getByText('Market details')).toBeDefined(); }); diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx index 028fb65f225..bc4a9c635c6 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx @@ -238,9 +238,14 @@ const TokenDetails: React.FC = ({ asset }) => { ); }, [marketData, currentCurrency, isNonEvmAsset, conversionRate]); + const hasAddressAndDecimals = + tokenDetails.contractAddress && tokenDetails.tokenDecimal; return ( - {(asset.isETH || tokenMetadata || isNonEvmAsset) && ( + {(asset.isETH || + tokenMetadata || + isNonEvmAsset || + hasAddressAndDecimals) && ( )} {marketData && marketDetails && ( diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index 95a12574094..1b81b50867e 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -1,1853 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AssetOverview should render correctly 1`] = ` - - - - - Ethereum - ( - ETH - ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1D - - - - - 1W - - - - - 1M - - - - - 3M - - - - - 1Y - - - - - 3Y - - - - - - - - - - - Buy - - - - - - - - - - - - Swap - - - - - - - - - - - - Send - - - - - - - - - - - - Receive - - - - - - - - - Ethereum - - - 400 - - - 1500 - - - 0 ETH - - - - - - - -`; - -exports[`AssetOverview should render native balances even if there are no accounts for the asset chain in the state 1`] = ` - - - - - Ethereum - ( - ETH - ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1D - - - - - 1W - - - - - 1M - - - - - 3M - - - - - 1Y - - - - - 3Y - - - - - - - - - - - Buy - - - - - - - - - - - - Swap - - - - - - - - - - - - Send - - - - - - - - - - - - Receive - - - - - - - - - Ethereum - - - 400 - - - 1500 - - - 0 ETH - - - - - - - -`; - exports[`AssetOverview should render native balances when non evm network is selected 1`] = ` { aggregators: ['uniswap'], }; + const { decimals, ...assetWithoutDecimals } = mockAsset; + const result = getTokenDetails( - mockAsset, + assetWithoutDecimals as TokenI, false, '0x456', metadataWithoutDecimals, diff --git a/app/components/UI/AssetOverview/utils/getTokenDetails.ts b/app/components/UI/AssetOverview/utils/getTokenDetails.ts index b00c0e4a2e8..195236e4603 100644 --- a/app/components/UI/AssetOverview/utils/getTokenDetails.ts +++ b/app/components/UI/AssetOverview/utils/getTokenDetails.ts @@ -36,13 +36,12 @@ export const getTokenDetails = ( tokenList: '', }; } - return { contractAddress: tokenContractAddress ?? null, tokenDecimal: typeof tokenMetadata?.decimals === 'number' ? tokenMetadata.decimals - : null, + : (asset.decimals ?? null), tokenList: Array.isArray(tokenMetadata?.aggregators) ? tokenMetadata.aggregators.join(', ') : null, diff --git a/app/components/UI/Assets/hooks/useTrendingRequest/index.ts b/app/components/UI/Assets/hooks/useTrendingRequest/index.ts deleted file mode 100644 index 72696b59127..00000000000 --- a/app/components/UI/Assets/hooks/useTrendingRequest/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; -import { debounce } from 'lodash'; -import { CaipChainId } from '@metamask/utils'; -import { - getTrendingTokens, - SortTrendingBy, -} from '@metamask/assets-controllers'; -import { useStableArray } from '../../../Perps/hooks/useStableArray'; -import { - NetworkType, - useNetworksByNamespace, - ProcessedNetwork, -} 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 - */ -export const useTrendingRequest = (options: { - chainIds?: CaipChainId[]; - sortBy?: SortTrendingBy; - minLiquidity?: number; - minVolume24hUsd?: number; - maxVolume24hUsd?: number; - minMarketCap?: number; - maxMarketCap?: number; -}) => { - const { - chainIds: providedChainIds = [], - sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - } = options; - - // Get default networks when chainIds is empty - const { networks } = useNetworksByNamespace({ - networkType: NetworkType.Popular, - }); - - const { networksToUse } = useNetworksToUse({ - networks, - networkType: NetworkType.Popular, - }); - - // Use provided chainIds or default to popular networks - const chainIds = useMemo((): CaipChainId[] => { - if (providedChainIds.length > 0) { - return providedChainIds; - } - return networksToUse.map( - (network: ProcessedNetwork) => network.caipChainId, - ); - }, [providedChainIds, networksToUse]); - - const [results, setResults] = useState - > | null>(null); - 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 - 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 fetchTrendingTokens = useCallback(async () => { - if (!memoizedOptions.chainIds.length) { - // Increment request ID to invalidate any pending requests - ++requestIdRef.current; - setResults(null); - setIsLoading(false); - return; - } - - // Increment request ID to mark this as the current request - const currentRequestId = ++requestIdRef.current; - setIsLoading(true); - setError(null); - - try { - const trendingResults = await getTrendingTokens(memoizedOptions); - // Only update state if this is still the current request - if (currentRequestId === requestIdRef.current) { - setResults(trendingResults || null); - } - } catch (err) { - // Only update state if this is still the current request - if (currentRequestId === requestIdRef.current) { - setError(err as Error); - setResults(null); - } - } finally { - // Only update loading state if this is still the current request - if (currentRequestId === requestIdRef.current) { - setIsLoading(false); - } - } - }, [memoizedOptions]); - - const debouncedFetchTrendingTokens = useMemo( - () => debounce(fetchTrendingTokens, DEBOUNCE_WAIT), - [fetchTrendingTokens], - ); - - // 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) { - return; - } - - // Trigger new fetch - debouncedFetchTrendingTokens(); - - // Cleanup: cancel on unmount or when dependencies change - return () => { - debouncedFetchTrendingTokens.cancel(); - }; - }, [debouncedFetchTrendingTokens, stableChainIds]); - - return { - results: results || [], - isLoading, - error, - fetch: debouncedFetchTrendingTokens, - }; -}; diff --git a/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx index 71bda8f8393..3332b59e053 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx @@ -8,7 +8,6 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; jest.mock('../../../util/networks', () => ({ ...jest.requireActual('../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false), isTestNet: jest.fn().mockReturnValue(false), })); @@ -160,60 +159,7 @@ describe('DeFiPositionsControlBar', () => { expect(getByTestId('defi-positions-network-filter')).toBeDefined(); }); - it('should show current network name when isRemoveGlobalNetworkSelectorEnabled is false and single network', () => { - const mockState = createMockState({ - engine: { - backgroundState: { - NetworkController: { - provider: { - chainId: CHAIN_IDS.MAINNET, - type: 'mainnet', - }, - }, - MultichainNetworkController: { - isEvmSelected: true, - }, - PreferencesController: { - selectedAddress: '0x123', - }, - }, - }, - }); - - store = mockStore(mockState); - - const { getByText } = render( - - - , - ); - - expect(getByText('Ethereum Mainnet')).toBeDefined(); - }); - - it('should show popular networks text when isRemoveGlobalNetworkSelectorEnabled is false and isAllNetworks is true', () => { - const mockState = createMockState(); - store = mockStore(mockState); - - const networkControllerModule = jest.requireMock( - '../../../selectors/networkController', - ); - networkControllerModule.selectIsAllNetworks = () => true; - networkControllerModule.selectIsPopularNetwork = () => true; - - const { getByText } = render( - - - , - ); - - expect(getByText(strings('wallet.popular_networks'))).toBeDefined(); - }); - - it('should show enabled networks text when isRemoveGlobalNetworkSelectorEnabled is true and multiple networks enabled', () => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - + it('shows enabled networks text when multiple networks enabled', () => { const mockState = createMockState(); store = mockStore(mockState); @@ -226,10 +172,7 @@ describe('DeFiPositionsControlBar', () => { expect(getByText(strings('wallet.popular_networks'))).toBeDefined(); }); - it('should show current network name when isRemoveGlobalNetworkSelectorEnabled is true and single network enabled', () => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - + it('shows current network name when single network enabled', () => { const useCurrentNetworkInfoModule = jest.requireMock( '../../hooks/useCurrentNetworkInfo', ); @@ -260,10 +203,7 @@ describe('DeFiPositionsControlBar', () => { expect(getByText('Ethereum Mainnet')).toBeDefined(); }); - it('should show current network fallback when isRemoveGlobalNetworkSelectorEnabled is true and no network name', () => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - + it('shows current network fallback when no network name', () => { const useCurrentNetworkInfoModule = jest.requireMock( '../../hooks/useCurrentNetworkInfo', ); @@ -292,10 +232,7 @@ describe('DeFiPositionsControlBar', () => { expect(getByText(strings('wallet.current_network'))).toBeDefined(); }); - it('should navigate to network manager when isRemoveGlobalNetworkSelectorEnabled is true and filter button is pressed', () => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - + it('navigates to network manager when filter button is pressed', () => { const mockNavigation = { navigate: jest.fn(), }; @@ -323,37 +260,6 @@ describe('DeFiPositionsControlBar', () => { ); }); - it('should navigate to token filter when isRemoveGlobalNetworkSelectorEnabled is false and filter button is pressed', () => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - - const mockNavigation = { - navigate: jest.fn(), - }; - - const navigationModule = jest.requireMock('@react-navigation/native'); - navigationModule.useNavigation = () => mockNavigation; - - const mockState = createMockState(); - store = mockStore(mockState); - - const { getByTestId } = render( - - - , - ); - - const filterButton = getByTestId('defi-positions-network-filter'); - fireEvent.press(filterButton); - - expect(mockNavigation.navigate).toHaveBeenCalledWith( - 'RootModalFlow', - expect.objectContaining({ - screen: 'TokenFilter', - }), - ); - }); - it('should be disabled when on testnet', () => { const networksModule = jest.requireMock('../../../util/networks'); networksModule.isTestNet.mockReturnValue(true); diff --git a/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx index 7f5de8520ba..d50036e5a9c 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx @@ -7,7 +7,6 @@ import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletV jest.mock('../../../util/networks', () => ({ ...jest.requireActual('../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false), })); jest.mock('react-native-device-info', () => ({ @@ -194,17 +193,20 @@ describe('DeFiPositionsList', () => { beforeEach(() => { jest.clearAllMocks(); + const allPositions = + mockInitialState.engine.backgroundState.DeFiPositionsController + .allDeFiPositions[MOCK_ADDRESS_1] || {}; + const defiPositionsModule = jest.requireMock( '../../../selectors/defiPositionsController', ); defiPositionsModule.selectDeFiPositionsByAddress.mockReturnValue( - mockInitialState.engine.backgroundState.DeFiPositionsController - .allDeFiPositions[MOCK_ADDRESS_1], - ); - defiPositionsModule.selectDefiPositionsByEnabledNetworks.mockReturnValue( - mockInitialState.engine.backgroundState.DeFiPositionsController - .allDeFiPositions[MOCK_ADDRESS_1], + allPositions, ); + // Network Manager is now always enabled, so mock returns only enabled chain (0x1) + defiPositionsModule.selectDefiPositionsByEnabledNetworks.mockReturnValue({ + [MOCK_CHAIN_ID_1]: allPositions[MOCK_CHAIN_ID_1], + }); }); it('renders protocol name and aggregated value for selected account and chain', async () => { @@ -229,6 +231,17 @@ describe('DeFiPositionsList', () => { }); it('renders protocol name and aggregated value for all chains when all networks is selected', async () => { + // Override mock to return all enabled chains + const allPositions = + mockInitialState.engine.backgroundState.DeFiPositionsController + .allDeFiPositions[MOCK_ADDRESS_1] || {}; + const defiPositionsModule = jest.requireMock( + '../../../selectors/defiPositionsController', + ); + defiPositionsModule.selectDefiPositionsByEnabledNetworks.mockReturnValue( + allPositions, + ); + const { findByTestId, findByText } = renderWithProvider( , { @@ -344,13 +357,8 @@ describe('DeFiPositionsList', () => { expect(await findByText(`Explore DeFi`)).toBeOnTheScreen(); }); - describe('when isRemoveGlobalNetworkSelectorEnabled is true', () => { - beforeEach(() => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - }); - - it('uses defiPositionsByEnabledNetworks selector when feature flag is enabled', async () => { + describe('Network Manager Integration', () => { + it('uses defiPositionsByEnabledNetworks selector', async () => { const defiPositionsModule = jest.requireMock( '../../../selectors/defiPositionsController', ); @@ -463,7 +471,7 @@ describe('DeFiPositionsList', () => { expect(await findByText(`Explore DeFi`)).toBeOnTheScreen(); }); - it('shows control bar with enabled networks text when feature flag is enabled', async () => { + it('shows control bar with enabled networks text', async () => { const defiPositionsModule = jest.requireMock( '../../../selectors/defiPositionsController', ); @@ -629,6 +637,18 @@ describe('DeFiPositionsList', () => { }); it('renders multiple positions without scroll container when isHomepageRedesignV1Enabled is true', async () => { + // Override mock to return both enabled chains + const allPositions = + mockInitialState.engine.backgroundState.DeFiPositionsController + .allDeFiPositions[MOCK_ADDRESS_1] || {}; + const defiPositionsModule = jest.requireMock( + '../../../selectors/defiPositionsController', + ); + defiPositionsModule.selectDefiPositionsByEnabledNetworks.mockReturnValue({ + [MOCK_CHAIN_ID_1]: allPositions[MOCK_CHAIN_ID_1], + [MOCK_CHAIN_ID_2]: allPositions[MOCK_CHAIN_ID_2], + }); + const { findByTestId, findByText, queryByTestId } = renderWithProvider( , { diff --git a/app/components/UI/DeFiPositions/DeFiPositionsList.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.tsx index b53213e29ec..d16a880b2c5 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsList.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsList.tsx @@ -2,10 +2,6 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; import { strings } from '../../../../locales/i18n'; import { useSelector } from 'react-redux'; -import { - selectChainId, - selectIsAllNetworks, -} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { selectDeFiPositionsByAddress, @@ -32,7 +28,6 @@ import Icon, { } from '../../../component-library/components/Icons/Icon'; import { useStyles } from '../../hooks/useStyles'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks'; import { DefiEmptyState } from '../DefiEmptyState'; import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; import ConditionalScrollView from '../../../component-library/components-temp/ConditionalScrollView'; @@ -43,8 +38,6 @@ export interface DeFiPositionsListProps { const DeFiPositionsList: React.FC = () => { const { styles } = useStyles(styleSheet, undefined); - const isAllNetworks = useSelector(selectIsAllNetworks); - const currentChainId = useSelector(selectChainId) as Hex; const tokenSortConfig = useSelector(selectTokenSortConfig); const defiPositions = useSelector(selectDeFiPositionsByAddress); const defiPositionsByEnabledNetworks = useSelector( @@ -60,20 +53,9 @@ const DeFiPositionsList: React.FC = () => { return defiPositions; } - let chainFilteredDeFiPositions: { [key: Hex]: GroupedDeFiPositions }; - if (isRemoveGlobalNetworkSelectorEnabled()) { - chainFilteredDeFiPositions = defiPositionsByEnabledNetworks as { - [key: Hex]: GroupedDeFiPositions; - }; - } else if (isAllNetworks) { - chainFilteredDeFiPositions = defiPositions; - } else if (currentChainId in defiPositions) { - chainFilteredDeFiPositions = { - [currentChainId]: defiPositions[currentChainId], - }; - } else { - return []; - } + const chainFilteredDeFiPositions = defiPositionsByEnabledNetworks as { + [key: Hex]: GroupedDeFiPositions; + }; if (!chainFilteredDeFiPositions) { return []; @@ -100,13 +82,7 @@ const DeFiPositionsList: React.FC = () => { }; return sortAssets(defiPositionsList, defiSortConfig); - }, [ - defiPositions, - isAllNetworks, - currentChainId, - tokenSortConfig, - defiPositionsByEnabledNetworks, - ]); + }, [defiPositions, tokenSortConfig, defiPositionsByEnabledNetworks]); if (!formattedDeFiPositions) { if (formattedDeFiPositions === undefined) { diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index cd25449a6c5..4bcde1e8b57 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -23,10 +23,8 @@ import DeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager import { MetaMetrics, MetaMetricsEvents } from '../../../core/Analytics'; import { importAccountFromPrivateKey } from '../../../util/importAccountFromPrivateKey'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks'; import Device from '../../../util/device'; import generateTestId from '../../../../wdio/utils/generateTestId'; -import PickerNetwork from '../../../component-library/components/Pickers/PickerNetwork'; import { NAV_ANDROID_BACK_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/NetworksScreen.testids'; import { BACK_BUTTON_SIMPLE_WEBVIEW } from '../../../../wdio/screen-objects/testIDs/Components/SimpleWebView.testIds'; import Routes from '../../../constants/navigation/Routes'; @@ -603,14 +601,8 @@ export function getSendFlowTitle({ ), headerRight: () => ( @@ -1059,8 +1051,6 @@ export function getWalletNavbarOptions( } } - const isFeatureFlagEnabled = isRemoveGlobalNetworkSelectorEnabled(); - const handleHamburgerPress = () => { trackEvent( MetricsEventBuilder.createEventBuilder( @@ -1085,21 +1075,6 @@ export function getWalletNavbarOptions( style={innerStyles.headerContainer} includesTopInset variant={HeaderBaseVariant.Display} - startAccessory={ - !isFeatureFlagEnabled && ( - - - - ) - } endAccessory={ { @@ -1638,7 +1613,7 @@ export function getSwapsAmountNavbar(navigation, route, themeColors) { title={title} disableNetwork translate={false} - showSelectedNetwork={!isRemoveGlobalNetworkSelectorEnabled()} + showSelectedNetwork={false} /> ), headerLeft: () => , diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx index 0daf4af9330..d66e2cb4cba 100644 --- a/app/components/UI/Navbar/index.test.jsx +++ b/app/components/UI/Navbar/index.test.jsx @@ -72,7 +72,6 @@ jest.mock('../../../util/notifications', () => ({ })); jest.mock('../../../util/networks', () => ({ - isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false), getNetworkNameFromProviderConfig: jest.fn(() => 'Ethereum Mainnet'), })); diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 6ceb6015baa..b42643e2c5b 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -75,6 +75,7 @@ interface NetworkProps { onAccept?: () => void; autoSwitchNetwork?: boolean; allowNetworkSwitch?: boolean; + skipEnableNetwork?: boolean; } const NetworkModals = (props: NetworkProps) => { @@ -96,6 +97,7 @@ const NetworkModals = (props: NetworkProps) => { onAccept, autoSwitchNetwork, allowNetworkSwitch = true, + skipEnableNetwork = false, } = props; const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); @@ -237,7 +239,7 @@ const NetworkModals = (props: NetworkProps) => { ?.networkClientId; } - if (networkClientId) { + if (networkClientId && !skipEnableNetwork) { onUpdateNetworkFilter(); } diff --git a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx index 6d057c8830f..8fca94b7fb8 100644 --- a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx @@ -5,7 +5,9 @@ import { usePerpsLivePositions, usePerpsCloseAllCalculations, usePerpsCloseAllPositions, + usePerpsRewardAccountOptedIn, } from '../../hooks'; +import { InternalAccount } from '@metamask/keyring-internal-api'; // Mock all dependencies jest.mock('@react-navigation/native', () => ({ @@ -20,6 +22,7 @@ jest.mock('../../hooks', () => ({ usePerpsLivePositions: jest.fn(), usePerpsCloseAllCalculations: jest.fn(), usePerpsCloseAllPositions: jest.fn(), + usePerpsRewardAccountOptedIn: jest.fn(), })); jest.mock('../../hooks/stream', () => ({ @@ -110,6 +113,10 @@ const mockUsePerpsCloseAllPositions = usePerpsCloseAllPositions as jest.MockedFunction< typeof usePerpsCloseAllPositions >; +const mockUsePerpsRewardAccountOptedIn = + usePerpsRewardAccountOptedIn as jest.MockedFunction< + typeof usePerpsRewardAccountOptedIn + >; describe('PerpsCloseAllPositionsView', () => { const mockPositions = [ @@ -164,6 +171,21 @@ describe('PerpsCloseAllPositionsView', () => { error: null, }; + const mockRewardAccountOptedIn = { + accountOptedIn: true, + account: { + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + name: 'Test Account', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + }, + }; + beforeEach(() => { jest.clearAllMocks(); mockUsePerpsLivePositions.mockReturnValue({ @@ -172,6 +194,10 @@ describe('PerpsCloseAllPositionsView', () => { }); mockUsePerpsCloseAllCalculations.mockReturnValue(mockCalculations); mockUsePerpsCloseAllPositions.mockReturnValue(mockCloseAllHook); + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: mockRewardAccountOptedIn as unknown as InternalAccount, + }); }); it('renders loading state when initially loading positions', () => { @@ -271,4 +297,69 @@ describe('PerpsCloseAllPositionsView', () => { // Assert expect(getByText('perps.close_all_modal.description')).toBeTruthy(); }); + + it('calls usePerpsRewardAccountOptedIn with totalEstimatedPoints', () => { + // Arrange & Act + render(); + + // Assert + expect(mockUsePerpsRewardAccountOptedIn).toHaveBeenCalledWith( + mockCalculations.totalEstimatedPoints, + ); + }); + + it('passes accountOptedIn and rewardsAccount to PerpsCloseSummary', () => { + // Arrange + const mockAccount = { + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + name: 'Test Account', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + }; + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: mockAccount as unknown as InternalAccount, + }); + + // Act + render(); + + // Assert + expect(mockUsePerpsRewardAccountOptedIn).toHaveBeenCalled(); + }); + + it('handles null accountOptedIn value', () => { + // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: null, + account: null, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_all_modal.description')).toBeTruthy(); + expect(mockUsePerpsRewardAccountOptedIn).toHaveBeenCalled(); + }); + + it('handles false accountOptedIn value', () => { + // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: false, + account: null, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_all_modal.description')).toBeTruthy(); + expect(mockUsePerpsRewardAccountOptedIn).toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx index c8a1731d098..65bc8a1e42d 100644 --- a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx @@ -24,6 +24,7 @@ import { usePerpsLivePositions, usePerpsCloseAllCalculations, usePerpsCloseAllPositions, + usePerpsRewardAccountOptedIn, } from '../../hooks'; import { usePerpsLivePrices } from '../../hooks/stream'; import usePerpsToasts, { @@ -77,6 +78,10 @@ const PerpsCloseAllPositionsView: React.FC = ({ priceData, }); + // Check opt-in status for rewards + const { accountOptedIn, account: rewardsAccount } = + usePerpsRewardAccountOptedIn(calculations?.totalEstimatedPoints); + // Track screen viewed event usePerpsEventTracking({ eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, @@ -334,6 +339,8 @@ const PerpsCloseAllPositionsView: React.FC = ({ isLoadingFees={calculations.isLoading} isLoadingRewards={calculations.isLoading} hasRewardsError={calculations.hasError} + accountOptedIn={accountOptedIn} + rewardsAccount={rewardsAccount} enableTooltips={false} /> )} diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index a86795fe0bb..4c226dc34f1 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -3032,6 +3032,8 @@ describe('PerpsClosePositionView', () => { bonusBips: 250, feeDiscountPercentage: 15, isRefresh: false, + accountOptedIn: true, + account: null, }); // Act @@ -3058,6 +3060,8 @@ describe('PerpsClosePositionView', () => { bonusBips: undefined, feeDiscountPercentage: undefined, isRefresh: false, + accountOptedIn: null, + account: null, }); // Act @@ -3083,6 +3087,8 @@ describe('PerpsClosePositionView', () => { bonusBips: undefined, feeDiscountPercentage: undefined, isRefresh: false, + accountOptedIn: true, + account: null, }); // Act @@ -3108,6 +3114,8 @@ describe('PerpsClosePositionView', () => { bonusBips: undefined, feeDiscountPercentage: undefined, isRefresh: false, + accountOptedIn: true, + account: null, }); // Act @@ -3133,6 +3141,8 @@ describe('PerpsClosePositionView', () => { bonusBips: 500, feeDiscountPercentage: 25, isRefresh: false, + accountOptedIn: true, + account: null, }); // Act diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index b32cba890f3..2bad000b6dd 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -550,6 +550,8 @@ const PerpsClosePositionView: React.FC = () => { isLoadingFees={feeResults.isLoadingMetamaskFee} isLoadingRewards={rewardsState.isLoading} hasRewardsError={rewardsState.hasError} + accountOptedIn={rewardsState.accountOptedIn} + rewardsAccount={rewardsState.account} isInputFocused={isInputFocused} testIDs={{ feesTooltip: PerpsClosePositionViewSelectorsIDs.FEES_TOOLTIP_BUTTON, diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 363aa62d912..5ab2440f0f3 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -72,6 +72,7 @@ import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip' import PerpsNavigationCard, { type NavigationItem, } from '../../components/PerpsNavigationCard/PerpsNavigationCard'; +import PerpsMarketTradesList from '../../components/PerpsMarketTradesList'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; import TradingViewChart, { type TradingViewChartRef, @@ -751,10 +752,12 @@ const PerpsMarketDetailsView: React.FC = () => { /> - {/* Navigation Card Section */} - - - + {/* Recent Trades Section */} + {market?.symbol && ( + + + + )} {/* Risk Disclaimer Section */} @@ -773,6 +776,11 @@ const PerpsMarketDetailsView: React.FC = () => { + + {/* Navigation Card Section */} + + + diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index a82b1feafd2..c7836ba9531 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -237,6 +237,8 @@ jest.mock('../../hooks', () => ({ feeDiscountPercentage: undefined, hasError: false, isRefresh: false, + accountOptedIn: null, + account: undefined, })), usePerpsToasts: jest.fn(() => ({ showToast: jest.fn(), @@ -442,6 +444,26 @@ jest.mock('../../components/PerpsBottomSheetTooltip', () => createBottomSheetMock('perps-order-view-bottom-sheet-tooltip'), ); +// Mock AddRewardsAccount component +jest.mock( + '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount', + () => { + const React = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ account }: { account?: unknown }) => + account + ? React.createElement( + View, + { testID: 'add-rewards-account' }, + React.createElement(Text, {}, 'Add Rewards Account'), + ) + : null, + }; + }, +); + // Test setup const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -1914,6 +1936,8 @@ describe('PerpsOrderView', () => { bonusBips: 250, feeDiscountPercentage: 15, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -1935,6 +1959,8 @@ describe('PerpsOrderView', () => { bonusBips: 500, feeDiscountPercentage: 20, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -1958,6 +1984,8 @@ describe('PerpsOrderView', () => { bonusBips: 250, feeDiscountPercentage: 15, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -1981,6 +2009,8 @@ describe('PerpsOrderView', () => { bonusBips: undefined, feeDiscountPercentage: undefined, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -2002,6 +2032,8 @@ describe('PerpsOrderView', () => { bonusBips: undefined, feeDiscountPercentage: undefined, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -2024,6 +2056,8 @@ describe('PerpsOrderView', () => { bonusBips: 500, // 5% bonus feeDiscountPercentage: 25, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -2035,6 +2069,95 @@ describe('PerpsOrderView', () => { expect(screen.getByText('2,500')).toBeTruthy(); }); }); + + it('renders AddRewardsAccount when accountOptedIn is false and account is defined', async () => { + // Arrange - Account not opted in but account exists + const mockAccount = { + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa' as const, + scopes: ['eip155:1'], + options: {}, + methods: [], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }; + + (usePerpsRewards as jest.Mock).mockReturnValue({ + shouldShowRewardsRow: true, + estimatedPoints: 100, + isLoading: false, + hasError: false, + bonusBips: 250, + feeDiscountPercentage: 15, + isRefresh: false, + accountOptedIn: false, + account: mockAccount, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert - Verify AddRewardsAccount component is rendered + await waitFor(() => { + expect(screen.getByText('perps.estimated_points')).toBeTruthy(); + expect(screen.getByTestId('add-rewards-account')).toBeTruthy(); + expect(screen.getByText('Add Rewards Account')).toBeTruthy(); + }); + }); + + it('renders RewardsAnimations when accountOptedIn is true', async () => { + // Arrange - Account opted in + (usePerpsRewards as jest.Mock).mockReturnValue({ + shouldShowRewardsRow: true, + estimatedPoints: 100, + isLoading: false, + hasError: false, + bonusBips: 250, + feeDiscountPercentage: 15, + isRefresh: false, + accountOptedIn: true, + account: undefined, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert - Verify RewardsAnimations is rendered, not AddRewardsAccount + await waitFor(() => { + expect(screen.getByText('perps.estimated_points')).toBeTruthy(); + expect(screen.queryByTestId('add-rewards-account')).toBeNull(); + }); + }); + + it('does not render rewards row when accountOptedIn is null', async () => { + // Arrange - Account opt-in status unknown + (usePerpsRewards as jest.Mock).mockReturnValue({ + shouldShowRewardsRow: false, + estimatedPoints: undefined, + isLoading: false, + hasError: false, + bonusBips: undefined, + feeDiscountPercentage: undefined, + isRefresh: false, + accountOptedIn: null, + account: undefined, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert - Verify rewards row is not rendered + await waitFor(() => { + expect(screen.queryByText('perps.estimated_points')).toBeNull(); + expect(screen.queryByTestId('add-rewards-account')).toBeNull(); + }); + }); }); describe('Info icon tooltip interactions', () => { @@ -2177,6 +2300,8 @@ describe('PerpsOrderView', () => { feeDiscountPercentage: 15, // 15% discount hasError: false, isRefresh: false, + accountOptedIn: true, + account: undefined, }); render(, { wrapper: TestWrapper }); @@ -2199,6 +2324,8 @@ describe('PerpsOrderView', () => { feeDiscountPercentage: undefined, // No discount hasError: false, isRefresh: false, + accountOptedIn: null, + account: undefined, }); render(, { wrapper: TestWrapper }); @@ -2222,6 +2349,8 @@ describe('PerpsOrderView', () => { feeDiscountPercentage: 20, hasError: false, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Act @@ -2408,6 +2537,8 @@ describe('PerpsOrderView', () => { feeDiscountPercentage: 12, // 12% fee discount hasError: false, isRefresh: false, + accountOptedIn: true, + account: undefined, }); // Mock valid order form @@ -2730,12 +2861,15 @@ describe('PerpsOrderView', () => { it('should show points tooltip when points info icon is pressed', async () => { // Arrange - Mock rewards to be enabled and showing (usePerpsRewards as jest.Mock).mockReturnValue({ - rewardsState: { - isEnabled: true, - shouldShow: true, - estimatedPoints: 25, - feeDiscountPercentage: 10, - }, + shouldShowRewardsRow: true, + isLoading: false, + estimatedPoints: 25, + bonusBips: undefined, + feeDiscountPercentage: 10, + hasError: false, + isRefresh: false, + accountOptedIn: true, + account: undefined, }); const { queryByTestId } = render( diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 114b4356f95..af7823fb06e 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -50,6 +50,7 @@ import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import RewardsAnimations, { RewardAnimationState, } from '../../../Rewards/components/RewardPointsAnimation'; +import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; import PerpsAmountDisplay from '../../components/PerpsAmountDisplay'; import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; @@ -1166,7 +1167,10 @@ const PerpsOrderViewContentBase: React.FC = () => { {/* Rewards Points Estimation */} {rewardsState.shouldShowRewardsRow && - rewardsState.estimatedPoints !== undefined && ( + rewardsState.estimatedPoints !== undefined && + (rewardsState.accountOptedIn || + (rewardsState.accountOptedIn === false && + rewardsState.account !== undefined)) && ( { - - openTooltipModal( - strings('perps.points_error'), - strings('perps.points_error_content'), - ) - } - state={rewardAnimationState} - /> + {rewardsState.accountOptedIn ? ( + + openTooltipModal( + strings('perps.points_error'), + strings('perps.points_error_content'), + ) + } + state={rewardAnimationState} + /> + ) : ( + + )} )} diff --git a/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts b/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts index ac41b290197..6c1adc9d3cf 100644 --- a/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts +++ b/app/components/UI/Perps/__mocks__/perpsHooksMocks.ts @@ -153,6 +153,8 @@ export const defaultPerpsRewardsMock = { feeDiscountPercentage: undefined, hasError: false, isRefresh: false, + accountOptedIn: null, + account: null, }; /** diff --git a/app/components/UI/Perps/__mocks__/providerMocks.ts b/app/components/UI/Perps/__mocks__/providerMocks.ts index 8f70abd8104..1a55e949074 100644 --- a/app/components/UI/Perps/__mocks__/providerMocks.ts +++ b/app/components/UI/Perps/__mocks__/providerMocks.ts @@ -22,7 +22,9 @@ export const createMockHyperLiquidProvider = placeOrder: jest.fn(), editOrder: jest.fn(), cancelOrder: jest.fn(), + cancelOrders: jest.fn(), closePosition: jest.fn(), + closePositions: jest.fn(), withdraw: jest.fn(), getDepositRoutes: jest.fn(), getWithdrawalRoutes: jest.fn(), diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts new file mode 100644 index 00000000000..be7e9071599 --- /dev/null +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -0,0 +1,115 @@ +/** + * Shared service mocks for Perps service tests + * Provides reusable mock implementations for ServiceContext and related types + */ + +import type { IMetaMetrics } from '../../../../core/Analytics/MetaMetrics.types'; +import type { ServiceContext } from '../controllers/services/ServiceContext'; +import type { + PerpsControllerState, + InitializationState, +} from '../controllers/PerpsController'; + +/** + * Create a mock IMetaMetrics instance + */ +export const createMockAnalytics = (): jest.Mocked => + ({ + isEnabled: jest.fn(() => true), + enable: jest.fn(), + enableSocialLogin: jest.fn(), + addTraitsToUser: jest.fn(), + group: jest.fn(), + trackEvent: jest.fn(), + trackAnonymousEvent: jest.fn(), + }) as unknown as jest.Mocked; + +/** + * Create a mock PerpsControllerState + */ +export const createMockPerpsControllerState = ( + overrides: Partial = {}, +): PerpsControllerState => ({ + activeProvider: 'hyperliquid', + isTestnet: false, + connectionStatus: 'connected', + initializationState: 'initialized' as InitializationState, + initializationError: null, + initializationAttempts: 0, + accountState: null, + positions: [], + perpsBalances: {}, + depositInProgress: false, + lastDepositTransactionId: null, + lastDepositResult: null, + withdrawInProgress: false, + lastWithdrawResult: null, + withdrawalRequests: [], + withdrawalProgress: { + progress: 0, + lastUpdated: 0, + activeWithdrawalId: null, + }, + depositRequests: [], + isEligible: true, + isFirstTimeUser: { + testnet: true, + mainnet: true, + }, + hasPlacedFirstOrder: { + testnet: false, + mainnet: false, + }, + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + tradeConfigurations: { + testnet: {}, + mainnet: {}, + }, + marketFilterPreferences: 'volume', + lastError: null, + lastUpdateTimestamp: Date.now(), + hip3ConfigVersion: 0, + ...overrides, +}); + +/** + * Create a mock ServiceContext with optional overrides + */ +export const createMockServiceContext = ( + overrides: Partial = {}, +): ServiceContext => ({ + tracingContext: { + provider: 'hyperliquid', + isTestnet: false, + }, + analytics: createMockAnalytics(), + errorContext: { + controller: 'TestService', + method: 'testMethod', + }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + ...overrides, +}); + +/** + * Create a mock EVM account (KeyringAccount) + */ +export const createMockEvmAccount = () => ({ + id: '00000000-0000-0000-0000-000000000000', + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + type: 'eip155:eoa' as const, + options: {}, + scopes: ['eip155:1'], + methods: ['eth_signTransaction', 'eth_sign'], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, +}); diff --git a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx index 9d67ef2ed28..a1194ea30a7 100644 --- a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx +++ b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import PerpsCloseSummary from './PerpsCloseSummary'; import { strings } from '../../../../../../locales/i18n'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; // Mock dependencies jest.mock('../../../../../../locales/i18n', () => ({ @@ -53,7 +54,36 @@ jest.mock('../../../Rewards/components/RewardPointsAnimation', () => ({ }, })); +jest.mock( + '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + React.createElement( + View, + { testID: 'add-rewards-account' }, + 'Add Rewards Account', + ), + }; + }, +); + describe('PerpsCloseSummary', () => { + const createMockAccount = (): InternalAccount => + ({ + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + }) as InternalAccount; + const defaultProps = { totalMargin: 1000, totalPnl: 150, @@ -120,11 +150,12 @@ describe('PerpsCloseSummary', () => { expect(getByTestId('receive-tooltip-button')).toBeTruthy(); }); - it('renders rewards section when enabled', () => { + it('renders rewards section when enabled and account opted in', () => { // Arrange const props = { ...defaultProps, shouldShowRewards: true, + accountOptedIn: true, estimatedPoints: 100, bonusBips: 500, }; @@ -136,11 +167,12 @@ describe('PerpsCloseSummary', () => { expect(getByText('perps.estimated_points')).toBeTruthy(); }); - it('renders rewards with loading state', () => { + it('renders rewards with loading state when account opted in', () => { // Arrange const props = { ...defaultProps, shouldShowRewards: true, + accountOptedIn: true, isLoadingRewards: true, estimatedPoints: 0, }; @@ -197,11 +229,12 @@ describe('PerpsCloseSummary', () => { expect(queryByText('PerpsFeesDisplay')).toBeNull(); }); - it('displays error state when rewards calculation fails', () => { + it('displays error state when rewards calculation fails and account opted in', () => { // Arrange const props = { ...defaultProps, shouldShowRewards: true, + accountOptedIn: true, hasRewardsError: true, estimatedPoints: 0, }; @@ -241,10 +274,11 @@ describe('PerpsCloseSummary', () => { expect(getByTestId('receive-tooltip')).toBeTruthy(); }); - it('handles tooltip press to open points tooltip when rewards enabled', () => { + it('handles tooltip press to open points tooltip when rewards enabled and account opted in', () => { const props = { ...defaultProps, shouldShowRewards: true, + accountOptedIn: true, enableTooltips: true, estimatedPoints: 100, testIDs: { pointsTooltip: 'points-tooltip' }, @@ -268,4 +302,91 @@ describe('PerpsCloseSummary', () => { expect(queryByTestId('fees-tooltip')).toBeNull(); }); + + it('renders AddRewardsAccount when account not opted in and rewardsAccount is provided', () => { + // Arrange + const mockAccount = createMockAccount(); + const props = { + ...defaultProps, + shouldShowRewards: true, + accountOptedIn: false, + rewardsAccount: mockAccount, + }; + + // Act + const { getByTestId, getByText } = render(); + + // Assert + expect(getByText('perps.estimated_points')).toBeTruthy(); + expect(getByTestId('add-rewards-account')).toBeTruthy(); + }); + + it('renders RewardsAnimations when account opted in', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + accountOptedIn: true, + estimatedPoints: 100, + bonusBips: 500, + }; + + // Act + const { getByText, queryByTestId } = render( + , + ); + + // Assert + expect(getByText('perps.estimated_points')).toBeTruthy(); + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('does not render rewards section when accountOptedIn is null', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + accountOptedIn: null, + estimatedPoints: 100, + }; + + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText('perps.estimated_points')).toBeNull(); + }); + + it('does not render rewards section when accountOptedIn is undefined', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + accountOptedIn: undefined, + estimatedPoints: 100, + }; + + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText('perps.estimated_points')).toBeNull(); + }); + + it('does not render rewards section when accountOptedIn is false and rewardsAccount is undefined', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + accountOptedIn: false, + rewardsAccount: undefined, + estimatedPoints: 100, + }; + + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText('perps.estimated_points')).toBeNull(); + }); }); diff --git a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx index 40e00344a86..df6d3d89bff 100644 --- a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx +++ b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx @@ -25,9 +25,11 @@ import { type PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip/PerpsBot import RewardsAnimations, { RewardAnimationState, } from '../../../Rewards/components/RewardPointsAnimation'; +import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; import { useStyles } from '../../../../hooks/useStyles'; import createStyles from './PerpsCloseSummary.styles'; import Routes from '../../../../../constants/navigation/Routes'; +import { InternalAccount } from '@metamask/keyring-internal-api'; export interface PerpsCloseSummaryProps { /** Total margin including P&L */ @@ -61,7 +63,10 @@ export interface PerpsCloseSummaryProps { isLoadingRewards?: boolean; /** Whether there was an error calculating rewards */ hasRewardsError?: boolean; - + /** Whether the account has opted in to rewards */ + accountOptedIn?: boolean | null; + /** The account that is currently in scope */ + rewardsAccount?: InternalAccount | null; /** Optional styling for container */ style?: ViewStyle; /** Whether input is focused (for padding adjustment) */ @@ -105,6 +110,8 @@ const PerpsCloseSummary: React.FC = ({ isLoadingFees = false, isLoadingRewards = false, hasRewardsError = false, + accountOptedIn = null, + rewardsAccount = undefined, style, isInputFocused = false, enableTooltips = true, @@ -266,40 +273,46 @@ const PerpsCloseSummary: React.FC = ({ {/* Estimated Points */} - {shouldShowRewards && ( - - - {enableTooltips ? ( - handleTooltipPress('points')} - style={styles.labelWithTooltip} - testID={testIDs?.pointsTooltip} - > + {shouldShowRewards && + (accountOptedIn || + (accountOptedIn === false && rewardsAccount !== undefined)) && ( + + + {enableTooltips ? ( + handleTooltipPress('points')} + style={styles.labelWithTooltip} + testID={testIDs?.pointsTooltip} + > + + {strings('perps.estimated_points')} + + + + ) : ( {strings('perps.estimated_points')} - + + {accountOptedIn ? ( + - - ) : ( - - {strings('perps.estimated_points')} - - )} - - - + ) : ( + + )} + - - )} + )} ); }; diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts new file mode 100644 index 00000000000..02e3a86ddfb --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts @@ -0,0 +1,52 @@ +import type { Theme } from '../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + width: '100%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + tradeItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border.muted, + }, + lastTradeItem: { + borderBottomWidth: 0, + }, + leftSection: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + iconContainer: { + marginRight: 12, + }, + tradeInfo: { + flex: 1, + }, + tradeType: { + marginBottom: 2, + }, + rightSection: { + alignItems: 'flex-end', + }, + emptyText: { + textAlign: 'center', + paddingVertical: 24, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx new file mode 100644 index 00000000000..8a95a5eba5b --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx @@ -0,0 +1,657 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import PerpsMarketTradesList from './PerpsMarketTradesList'; +import Routes from '../../../../../constants/navigation/Routes'; +import { usePerpsOrderFills } from '../../hooks/usePerpsOrderFills'; +import type { OrderFill } from '../../controllers/types'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), +})); + +jest.mock('../../hooks/usePerpsOrderFills'); + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + container: {}, + header: {}, + tradeItem: {}, + lastTradeItem: {}, + leftSection: {}, + iconContainer: {}, + tradeInfo: {}, + tradeType: {}, + tradeAmount: {}, + rightSection: {}, + emptyText: {}, + }, + }), +})); + +jest.mock('../../../../../component-library/components/Texts/Text', () => { + const ReactLib = jest.requireActual('react'); + const { Text: ReactNativeText } = jest.requireActual('react-native'); + + const MockText = ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => ReactLib.createElement(ReactNativeText, props, children); + + return { + __esModule: true, + default: MockText, + TextVariant: { + HeadingSM: 'HeadingSM', + BodyMD: 'BodyMD', + BodyMDMedium: 'BodyMDMedium', + BodySM: 'BodySM', + }, + TextColor: { + Default: 'Default', + Alternative: 'Alternative', + Success: 'Success', + Error: 'Error', + }, + }; +}); + +jest.mock('../PerpsTokenLogo', () => { + const { View: RNView, Text: RNText } = jest.requireActual('react-native'); + return function MockPerpsTokenLogo({ + symbol, + size, + recyclingKey, + }: { + symbol: string; + size: number; + recyclingKey: string; + }) { + return ( + + {size} + {recyclingKey} + + ); + }; +}); + +jest.mock('../PerpsRowSkeleton', () => { + const { View: RNView } = jest.requireActual('react-native'); + return function MockPerpsRowSkeleton({ count }: { count: number }) { + return ; + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'perps.market.recent_trades': 'Recent activity', + 'perps.home.see_all': 'See all', + 'perps.market.no_trades': 'No recent activity', + }; + return translations[key] || key; + }, +})); + +jest.mock('../../utils/marketUtils', () => ({ + getPerpsDisplaySymbol: (symbol: string) => symbol, +})); + +describe('PerpsMarketTradesList', () => { + const mockNavigate = jest.fn(); + const mockUsePerpsOrderFills = usePerpsOrderFills as jest.MockedFunction< + typeof usePerpsOrderFills + >; + + const mockOrderFills: OrderFill[] = [ + { + orderId: 'fill-1', + symbol: 'ETH', + side: 'buy', + size: '1.5', + price: '2500', + fee: '10.5', + feeToken: 'USDC', + timestamp: 1698700000000, + pnl: '0', + direction: 'Open Long', + success: true, + }, + { + orderId: 'fill-2', + symbol: 'ETH', + side: 'sell', + size: '2.0', + price: '2600', + fee: '5.0', + feeToken: 'USDC', + timestamp: 1698690000000, + pnl: '150', + direction: 'Close Long', + success: true, + }, + { + orderId: 'fill-3', + symbol: 'ETH', + side: 'sell', + size: '1.0', + price: '2550', + fee: '5.0', + feeToken: 'USDC', + timestamp: 1698680000000, + pnl: '0', + direction: 'Open Short', + success: true, + }, + { + orderId: 'fill-4', + symbol: 'BTC', + side: 'buy', + size: '0.5', + price: '45000', + fee: '10.0', + feeToken: 'USDC', + timestamp: 1698670000000, + pnl: '0', + direction: 'Open Long', + success: true, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + const { useNavigation } = jest.requireMock('@react-navigation/native'); + useNavigation.mockReturnValue({ + navigate: mockNavigate, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Loading State', () => { + it('renders loading skeleton when hook is loading', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: true, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + }); + + it('renders header with title when loading', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: true, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Recent activity')).toBeOnTheScreen(); + }); + + it('does not render See all button when loading', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: true, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.queryByText('See all')).not.toBeOnTheScreen(); + }); + }); + + describe('Empty State', () => { + it('renders empty message when trades array is empty', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('No recent activity')).toBeOnTheScreen(); + }); + + it('renders header with title when empty', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Recent activity')).toBeOnTheScreen(); + }); + + it('does not render See all button when empty', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.queryByText('See all')).not.toBeOnTheScreen(); + }); + }); + + describe('Component Rendering', () => { + it('renders list with trades', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + expect(screen.getByText('Opened short')).toBeOnTheScreen(); + }); + + it('renders header with title and See all button', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Recent activity')).toBeOnTheScreen(); + expect(screen.getByText('See all')).toBeOnTheScreen(); + }); + + it('renders trade subtitles correctly', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('1.5 ETH')).toBeOnTheScreen(); + expect(screen.getByText('2.0 ETH')).toBeOnTheScreen(); + expect(screen.getByText('1.0 ETH')).toBeOnTheScreen(); + }); + + it('renders token logos for each trade', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const logos = screen.getAllByTestId(/perps-token-logo-ETH/); + expect(logos).toHaveLength(3); + }); + + it('renders fill amounts correctly', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('-$10.50')).toBeOnTheScreen(); + expect(screen.getByText('+$145.00')).toBeOnTheScreen(); + expect(screen.getByText('-$5.00')).toBeOnTheScreen(); + }); + + it('uses default icon size when not provided', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [mockOrderFills[0]], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const iconSizes = screen.getAllByTestId('logo-size'); + expect(iconSizes[0]).toHaveTextContent('36'); + }); + + it('uses custom icon size when provided', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [mockOrderFills[0]], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const iconSizes = screen.getAllByTestId('logo-size'); + expect(iconSizes[0]).toHaveTextContent('48'); + }); + }); + + describe('Navigation Handling', () => { + it('navigates to Activity screen when See all is pressed', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const seeAllButton = screen.getByText('See all'); + fireEvent.press(seeAllButton); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ACTIVITY, { + redirectToPerpsTransactions: true, + showBackButton: true, + }); + }); + + it('navigates to position transaction detail when trade item is pressed', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const tradeItem = screen.getByText('Opened long'); + fireEvent.press(tradeItem.parent?.parent || tradeItem); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + // Verify navigation to correct route with transaction param + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.POSITION_TRANSACTION, + expect.objectContaining({ + transaction: expect.objectContaining({ + id: 'fill-1', + type: 'trade', + category: 'position_open', + title: 'Opened long', + asset: 'ETH', + }), + }), + ); + }); + + it('navigates with correct transaction data for different trades', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const ethTrade = screen.getByText('Closed long'); + fireEvent.press(ethTrade.parent?.parent || ethTrade); + + // Verify navigation with correct transformed transaction data + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.POSITION_TRANSACTION, + expect.objectContaining({ + transaction: expect.objectContaining({ + id: 'fill-2', + type: 'trade', + category: 'position_close', + title: 'Closed long', + asset: 'ETH', + }), + }), + ); + }); + }); + + describe('Hook Integration', () => { + it('calls usePerpsOrderFills with correct params', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(mockUsePerpsOrderFills).toHaveBeenCalledWith({ + params: { aggregateByTime: false }, + }); + }); + + it('filters order fills by symbol', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + // Should only show ETH trades (3 out of 4 fills) + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + expect(screen.getByText('Opened short')).toBeOnTheScreen(); + }); + + it('limits trades to 3 even when more exist', () => { + const manyETHFills: OrderFill[] = Array.from({ length: 10 }, (_, i) => ({ + orderId: `fill-eth-${i}`, + symbol: 'ETH', + side: 'buy', + size: '1.0', + price: '2500', + fee: '5.0', + feeToken: 'USDC', + timestamp: 1698700000000 - i * 1000, + pnl: '0', + direction: 'Open Long', + success: true, + })); + + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: manyETHFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const logos = screen.getAllByTestId(/perps-token-logo-ETH/); + expect(logos).toHaveLength(3); + }); + }); + + describe('Edge Cases', () => { + it('handles single trade', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: [mockOrderFills[0]], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.queryByText('Closed long')).not.toBeOnTheScreen(); + }); + + it('handles exactly 3 trades', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills.slice(0, 3), + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + expect(screen.getByText('Opened short')).toBeOnTheScreen(); + }); + + it('filters out non-matching symbols', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, // Contains BTC and ETH fills + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + // Should only show BTC trade (1 out of 4 fills) + const logos = screen.getAllByTestId(/perps-token-logo/); + expect(logos).toHaveLength(1); + expect(screen.getByTestId('perps-token-logo-BTC')).toBeOnTheScreen(); + }); + + it('renders recycling key correctly for token logos', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills.slice(0, 3), + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + const logoKeys = screen.getAllByTestId('logo-key'); + expect(logoKeys[0]).toHaveTextContent('ETH-fill-1'); + expect(logoKeys[1]).toHaveTextContent('ETH-fill-2'); + expect(logoKeys[2]).toHaveTextContent('ETH-fill-3'); + }); + }); + + describe('Component Lifecycle', () => { + it('does not throw error on unmount', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + const { unmount } = render(); + + expect(() => unmount()).not.toThrow(); + }); + + it('updates when symbol prop changes', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + const { rerender } = render(); + + expect(screen.getAllByTestId(/perps-token-logo-ETH/)).toHaveLength(3); + + rerender(); + + // Should now show BTC trades + expect(screen.getByTestId('perps-token-logo-BTC')).toBeOnTheScreen(); + }); + }); + + describe('FlatList Configuration', () => { + it('uses transaction id as key extractor', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + expect(screen.getByText('Opened short')).toBeOnTheScreen(); + }); + + it('disables scroll on FlatList', () => { + mockUsePerpsOrderFills.mockReturnValue({ + orderFills: mockOrderFills, + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + const { root } = render(); + + expect(root).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx new file mode 100644 index 00000000000..6d126ff8ce3 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useMemo } from 'react'; +import { View, TouchableOpacity, FlatList } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { PerpsNavigationParamList } from '../../controllers/types'; +import type { PerpsTransaction } from '../../types/transactionHistory'; +import PerpsTokenLogo from '../PerpsTokenLogo'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './PerpsMarketTradesList.styles'; +import PerpsRowSkeleton from '../PerpsRowSkeleton'; +import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; +import { usePerpsOrderFills } from '../../hooks/usePerpsOrderFills'; +import { transformFillsToTransactions } from '../../utils/transactionTransforms'; +import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; + +interface PerpsMarketTradesListProps { + symbol: string; // Market symbol to filter trades + iconSize?: number; +} + +const PerpsMarketTradesList: React.FC = ({ + symbol, + iconSize = 36, +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation>(); + + // Memoize params to prevent infinite refetch loop + const orderFillsParams = useMemo(() => ({ aggregateByTime: false }), []); + + // Fetch all order fills using existing hook + const { orderFills, isLoading } = usePerpsOrderFills({ + params: orderFillsParams, + }); + + // Filter by symbol, transform, and limit to 3 + const trades = useMemo(() => { + // Filter fills for this market + const marketFills = orderFills.filter((fill) => fill.symbol === symbol); + + // Sort by timestamp descending (newest first) + marketFills.sort((a, b) => b.timestamp - a.timestamp); + + // Transform to transactions + const transactions = transformFillsToTransactions(marketFills); + + // Limit to 3 + return transactions.slice(0, PERPS_CONSTANTS.RECENT_ACTIVITY_LIMIT); + }, [orderFills, symbol]); + + const handleSeeAll = useCallback(() => { + // Navigate to Activity > Trades tab + navigation.navigate(Routes.PERPS.ACTIVITY, { + redirectToPerpsTransactions: true, + showBackButton: true, + }); + }, [navigation]); + + const handleTradePress = useCallback( + (transaction: PerpsTransaction) => { + // Navigate to the position transaction detail screen + navigation.navigate(Routes.PERPS.POSITION_TRANSACTION, { + transaction, + }); + }, + [navigation], + ); + + // Render right content for trades + const renderRightContent = useCallback((transaction: PerpsTransaction) => { + if (!transaction.fill) return null; + + const pnlColor = transaction.fill.isPositive + ? TextColor.Success + : TextColor.Error; + return ( + + {transaction.fill.amount} + + ); + }, []); + + const renderItem = useCallback( + (props: { item: PerpsTransaction; index: number }) => { + const { item, index } = props; + const isLastItem = index === trades.length - 1; + + return ( + handleTradePress(item)} + activeOpacity={0.7} + > + + + + + + + {item.title} + + {!!item.subtitle && ( + + {getPerpsDisplaySymbol(item.subtitle)} + + )} + + + {renderRightContent(item)} + + ); + }, + [styles, handleTradePress, iconSize, renderRightContent, trades.length], + ); + + // Render header section + const renderHeader = () => ( + + + {strings('perps.market.recent_trades')} + + {!isLoading && trades.length > 0 && ( + + + {strings('perps.home.see_all')} + + + )} + + ); + + // Render content based on state + const renderContent = () => { + if (isLoading) { + return ; + } + + if (trades.length === 0) { + return ( + + {strings('perps.market.no_trades')} + + ); + } + + return ( + `${item.id || index}`} + scrollEnabled={false} + /> + ); + }; + + return ( + + {renderHeader()} + {renderContent()} + + ); +}; + +export default PerpsMarketTradesList; diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/index.ts b/app/components/UI/Perps/components/PerpsMarketTradesList/index.ts new file mode 100644 index 00000000000..c2416225a96 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/index.ts @@ -0,0 +1 @@ +export { default } from './PerpsMarketTradesList'; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 287c3ad2081..5d193261407 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -29,6 +29,8 @@ export const PERPS_CONSTANTS = { FALLBACK_DATA_DISPLAY: '--', // Display when non-price data is unavailable ZERO_AMOUNT_DISPLAY: '$0', // Display for zero dollar amounts (e.g., no volume) ZERO_AMOUNT_DETAILED_DISPLAY: '$0.00', // Display for zero dollar amounts with decimals + + RECENT_ACTIVITY_LIMIT: 3, } as const; /** diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index a63decf46a0..26b06e7bff5 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -8,17 +8,23 @@ import { PerpsController, getDefaultPerpsControllerState, + InitializationState, + type PerpsControllerState, + type PerpsControllerMessenger, } from './PerpsController'; +import { PERPS_ERROR_CODES } from './perpsErrorCodes'; +import type { IPerpsProvider } from './types'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; -import { - createMockHyperLiquidProvider, - createMockOrder, - createMockPosition, -} from '../__mocks__/providerMocks'; -import { MetaMetrics } from '../../../../core/Analytics'; +import { createMockHyperLiquidProvider } from '../__mocks__/providerMocks'; import Logger from '../../../../util/Logger'; +import { FeatureFlagConfigurationService } from './services/FeatureFlagConfigurationService'; +import { DepositService } from './services/DepositService'; +import { MarketDataService } from './services/MarketDataService'; +import { TradingService } from './services/TradingService'; +import { AccountService } from './services/AccountService'; +import { DataLakeService } from './services/DataLakeService'; +import Engine from '../../../../core/Engine'; -// Mock the HyperLiquidProvider jest.mock('./providers/HyperLiquidProvider'); // Mock wait utility to speed up retry tests @@ -77,25 +83,32 @@ jest.mock('../../../../core/Analytics/MetricsEventBuilder', () => ({ }, })); -const mockRewardsController = { - getPerpsDiscountForAccount: jest.fn(), -}; +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock('../../../../core/Engine', () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; -const mockNetworkController = { - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { chainId: '0x1' }, - }), -}; + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + TransactionController: {}, + }; -jest.mock('../../../../core/Engine', () => ({ - Engine: { - context: { - RewardsController: mockRewardsController, - NetworkController: mockNetworkController, - TransactionController: {}, + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, }, - }, -})); + }; +}); jest.mock('../../../../util/accounts', () => ({ getEvmAccountFromSelectedAccountGroup: jest.fn().mockReturnValue({ @@ -110,28 +123,312 @@ jest.mock('@metamask/utils', () => ({ .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), })); +// Mock EligibilityService to prevent actual geo-location fetching in tests +jest.mock('./services/EligibilityService', () => ({ + EligibilityService: { + checkEligibility: jest.fn().mockResolvedValue(true), + fetchGeoLocation: jest.fn().mockResolvedValue('UNKNOWN'), + clearCache: jest.fn(), + }, +})); + +// Mock DepositService +jest.mock('./services/DepositService', () => ({ + DepositService: { + prepareTransaction: jest.fn(), + }, +})); + +// Mock MarketDataService +jest.mock('./services/MarketDataService', () => ({ + MarketDataService: { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), + }, +})); + +// Mock TradingService +jest.mock('./services/TradingService', () => ({ + TradingService: { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + }, +})); + +// Mock AccountService +jest.mock('./services/AccountService', () => ({ + AccountService: { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), + }, +})); + +// Mock DataLakeService +jest.mock('./services/DataLakeService', () => ({ + DataLakeService: { + reportOrder: jest.fn(), + }, +})); + +// Mock FeatureFlagConfigurationService +jest.mock('./services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: { + refreshEligibility: jest.fn((options) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + const mockRefreshHip3Config = jest.requireMock( + './services/FeatureFlagConfigurationService', + ).FeatureFlagConfigurationService.refreshHip3Config; + if (typeof mockRefreshHip3Config === 'function') { + mockRefreshHip3Config(options); + } + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), + }, +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.INITIALIZED; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + */ + public testSetProviders(providers: Map) { + this.providers = providers; + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } +} + +/** + * Factory function to create a properly typed mock messenger + * Encapsulates the type assertion in one place + * Note: Uses 'as unknown as' because PerpsControllerMessenger has private properties + */ +function createMockMessenger( + overrides?: Partial, +): PerpsControllerMessenger { + const base = { + call: jest.fn(), + publish: jest.fn(), + subscribe: jest.fn(), + registerActionHandler: jest.fn(), + registerEventHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + unregisterActionHandler: jest.fn(), + unregisterEventHandler: jest.fn(), + clearEventSubscriptions: jest.fn(), + }; + return { ...base, ...overrides } as unknown as PerpsControllerMessenger; +} + describe('PerpsController', () => { - let controller: PerpsController; + let controller: TestablePerpsController; let mockProvider: jest.Mocked; // Helper to mark controller as initialized for tests const markControllerAsInitialized = () => { - (controller as any).isInitialized = true; - (controller as any).update((state: any) => { - state.initializationState = 'initialized'; - }); + controller.testMarkInitialized(); }; beforeEach(() => { + jest.clearAllMocks(); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + // Create a fresh mock provider for each test mockProvider = createMockHyperLiquidProvider(); - // Mock the HyperLiquidProvider constructor to return our mock + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + availableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + ( HyperLiquidProvider as jest.MockedClass ).mockImplementation(() => mockProvider); - // Create mock messenger call function that handles RemoteFeatureFlagController:getState const mockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { return { @@ -145,22 +442,29 @@ describe('PerpsController', () => { return undefined; }); - // Create a new controller instance - controller = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), }); }); afterEach(() => { - jest.clearAllMocks(); + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + mockTrackEvent.mockClear(); + (Logger.error as jest.Mock).mockClear(); + (Logger.log as jest.Mock).mockClear(); }); describe('constructor', () => { @@ -173,7 +477,9 @@ describe('PerpsController', () => { expect(controller.state.initializationState).toBe('uninitialized'); // Waits for explicit initialization expect(controller.state.initializationError).toBeNull(); expect(controller.state.initializationAttempts).toBe(0); // Not started yet - expect(controller.state.isEligible).toBe(false); + // isEligible is initially false, but refreshEligibility is called during construction + // which updates it to true (defaulting to eligible when geo-location is unknown) + expect(controller.state.isEligible).toBe(true); expect(controller.state.isTestnet).toBe(false); // Default to mainnet }); @@ -193,15 +499,8 @@ describe('PerpsController', () => { }); // When: Controller is constructed - const testController = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), }); @@ -212,7 +511,7 @@ describe('PerpsController', () => { ); }); - it('should apply remote blocked regions when available during construction', () => { + it('applies remote blocked regions when available during construction', () => { // Given: Remote feature flags with blocked regions const mockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -228,15 +527,8 @@ describe('PerpsController', () => { }); // When: Controller is constructed - const testController = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), clientConfig: { fallbackBlockedRegions: ['FALLBACK-REGION'], @@ -245,14 +537,12 @@ describe('PerpsController', () => { // Then: Should have used remote regions (not fallback) // Verify by checking the internal blockedRegionList - expect((testController as any).blockedRegionList.source).toBe('remote'); - expect((testController as any).blockedRegionList.list).toEqual([ - 'US-NY', - 'CA-ON', - ]); + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['US-NY', 'CA-ON']); }); - it('should use fallback regions when remote flags are not available', () => { + it('uses fallback regions when remote flags are not available', () => { // Given: Remote feature flags without blocked regions const mockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -264,15 +554,8 @@ describe('PerpsController', () => { }); // When: Controller is constructed with fallback regions - const testController = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), clientConfig: { fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], @@ -280,14 +563,12 @@ describe('PerpsController', () => { }); // Then: Should have used fallback regions - expect((testController as any).blockedRegionList.source).toBe('fallback'); - expect((testController as any).blockedRegionList.list).toEqual([ - 'FALLBACK-US', - 'FALLBACK-CA', - ]); + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('fallback'); + expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); }); - it('should never downgrade from remote to fallback regions', () => { + it('never downgrade from remote to fallback regions', () => { // Given: Remote feature flags with blocked regions const mockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -303,15 +584,8 @@ describe('PerpsController', () => { }); // When: Controller is constructed with both remote and fallback - const testController = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), clientConfig: { fallbackBlockedRegions: ['FALLBACK-US'], @@ -319,26 +593,20 @@ describe('PerpsController', () => { }); // Then: Should use remote (set after fallback) - expect((testController as any).blockedRegionList.source).toBe('remote'); - expect((testController as any).blockedRegionList.list).toEqual([ - 'REMOTE-US', - ]); + let blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['REMOTE-US']); // When: Attempt to set fallback again (simulating what setBlockedRegionList does) - (testController as any).setBlockedRegionList( - ['NEW-FALLBACK'], - 'fallback', - ); + testController.testSetBlockedRegionList(['NEW-FALLBACK'], 'fallback'); // Then: Should still use remote (no downgrade) - expect((testController as any).blockedRegionList.source).toBe('remote'); - expect((testController as any).blockedRegionList.list).toEqual([ - 'REMOTE-US', - ]); + blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['REMOTE-US']); }); it('continues initialization when RemoteFeatureFlagController state call throws error', () => { - // Arrange: Mock messenger that throws error for RemoteFeatureFlagController:getState const mockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { throw new Error('RemoteFeatureFlagController not ready'); @@ -347,29 +615,18 @@ describe('PerpsController', () => { }); const mockLoggerError = jest.spyOn(Logger, 'error'); - // Act: Construct controller with fallback regions - const testController = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), clientConfig: { fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], }, }); - // Assert: Controller initializes successfully and uses fallback expect(testController).toBeDefined(); - expect((testController as any).blockedRegionList.source).toBe('fallback'); - expect((testController as any).blockedRegionList.list).toEqual([ - 'FALLBACK-US', - 'FALLBACK-CA', - ]); + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('fallback'); + expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); expect(mockLoggerError).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ @@ -390,309 +647,54 @@ describe('PerpsController', () => { }); }); - describe('refreshHip3ConfigOnFeatureFlagChange', () => { - describe('allowlist parsing', () => { - it('parses comma-separated allowlist string from LaunchDarkly', () => { - // Arrange - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: 'BTC-USD,ETH-USD,SOL-USD', - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3AllowlistMarkets).toEqual([ - 'BTC-USD', - 'ETH-USD', - 'SOL-USD', - ]); - }); - - it('parses allowlist array format', () => { - // Arrange - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: ['BTC-USD', 'ETH-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3AllowlistMarkets).toEqual([ - 'BTC-USD', - 'ETH-USD', - ]); - }); - - it('trims whitespace from allowlist array items', () => { - // Arrange - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: [' BTC-USD ', ' ETH-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3AllowlistMarkets).toEqual([ - 'BTC-USD', - 'ETH-USD', - ]); - }); - - it('falls back to local config when allowlist format is invalid (non-string array)', () => { - // Arrange - const initialAllowlist = ['LOCAL-BTC']; - (controller as any).hip3AllowlistMarkets = initialAllowlist; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: [123, null, 'BTC-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3AllowlistMarkets).toEqual( - initialAllowlist, - ); - }); - - it('falls back to local config when allowlist format is invalid (empty string array)', () => { - // Arrange - const initialAllowlist = ['LOCAL-ETH']; - (controller as any).hip3AllowlistMarkets = initialAllowlist; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: ['BTC-USD', '', 'ETH-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3AllowlistMarkets).toEqual( - initialAllowlist, - ); - }); - - it('falls back to local config when allowlist is empty string after parsing', () => { - // Arrange - const initialAllowlist = ['LOCAL-SOL']; - (controller as any).hip3AllowlistMarkets = initialAllowlist; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: '', - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3AllowlistMarkets).toEqual( - initialAllowlist, - ); - }); - }); - - describe('blocklist parsing', () => { - it('parses comma-separated blocklist string from LaunchDarkly', () => { - // Arrange - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: 'SCAM-USD,FAKE-USD', - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3BlocklistMarkets).toEqual([ - 'SCAM-USD', - 'FAKE-USD', - ]); - }); - - it('parses blocklist array format', () => { - // Arrange - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: ['SCAM-USD', 'FAKE-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3BlocklistMarkets).toEqual([ - 'SCAM-USD', - 'FAKE-USD', - ]); - }); - - it('trims whitespace from blocklist array items', () => { - // Arrange - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: [' SCAM-USD ', ' FAKE-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3BlocklistMarkets).toEqual([ - 'SCAM-USD', - 'FAKE-USD', - ]); - }); - - it('falls back to local config when blocklist format is invalid (non-string array)', () => { - // Arrange - const initialBlocklist = ['LOCAL-SCAM']; - (controller as any).hip3BlocklistMarkets = initialBlocklist; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: [456, null, 'SCAM-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3BlocklistMarkets).toEqual( - initialBlocklist, - ); - }); - - it('falls back to local config when blocklist format is invalid (empty string array)', () => { - // Arrange - const initialBlocklist = ['LOCAL-FAKE']; - (controller as any).hip3BlocklistMarkets = initialBlocklist; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: ['SCAM-USD', '', 'FAKE-USD'], - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect((controller as any).hip3BlocklistMarkets).toEqual( - initialBlocklist, - ); - }); - - it('falls back to local config when blocklist is empty string after parsing', () => { - // Arrange - const initialBlocklist = ['LOCAL-BAD']; - (controller as any).hip3BlocklistMarkets = initialBlocklist; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: '', - }, - }; + describe('HIP-3 Configuration Integration', () => { + it('delegates HIP-3 config updates to FeatureFlagConfigurationService', () => { + const remoteFlags = { + remoteFeatureFlags: { + perpsHip3AllowlistMarkets: 'BTC-USD,ETH-USD', + perpsHip3BlocklistMarkets: 'SCAM-USD', + }, + }; - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); + controller.testRefreshEligibilityOnFeatureFlagChange(remoteFlags); - // Assert - expect((controller as any).hip3BlocklistMarkets).toEqual( - initialBlocklist, - ); + expect( + FeatureFlagConfigurationService.refreshEligibility, + ).toHaveBeenCalledWith({ + remoteFeatureFlagControllerState: remoteFlags, + context: expect.objectContaining({ + getHip3Config: expect.any(Function), + setHip3Config: expect.any(Function), + incrementHip3ConfigVersion: expect.any(Function), + }), }); }); - describe('config change detection', () => { - it('increments hip3ConfigVersion when allowlist changes', () => { - // Arrange - const initialVersion = controller.state.hip3ConfigVersion; - (controller as any).hip3AllowlistMarkets = ['BTC-USD']; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: 'ETH-USD,SOL-USD', - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect(controller.state.hip3ConfigVersion).toBe(initialVersion + 1); - expect((controller as any).hip3AllowlistMarkets).toEqual([ - 'ETH-USD', - 'SOL-USD', - ]); - }); - - it('increments hip3ConfigVersion when blocklist changes', () => { - // Arrange - const initialVersion = controller.state.hip3ConfigVersion; - (controller as any).hip3BlocklistMarkets = ['OLD-SCAM']; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3BlocklistMarkets: 'NEW-SCAM,NEW-FAKE', - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); - - // Assert - expect(controller.state.hip3ConfigVersion).toBe(initialVersion + 1); - expect((controller as any).hip3BlocklistMarkets).toEqual([ - 'NEW-SCAM', - 'NEW-FAKE', - ]); - }); - - it('does not increment version when config stays the same', () => { - // Arrange - const initialVersion = controller.state.hip3ConfigVersion; - (controller as any).hip3AllowlistMarkets = ['BTC-USD', 'ETH-USD']; - const remoteFlags = { - remoteFeatureFlags: { - perpsHip3AllowlistMarkets: 'ETH-USD,BTC-USD', // Same, just different order - }, - }; - - // Act - (controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags); + it('does not crash on malformed remote flags', () => { + const malformedFlags = { + remoteFeatureFlags: { + perpsHip3AllowlistMarkets: 123, + }, + }; - // Assert - expect(controller.state.hip3ConfigVersion).toBe(initialVersion); - }); + expect(() => + controller.testRefreshEligibilityOnFeatureFlagChange(malformedFlags), + ).not.toThrow(); }); }); describe('getActiveProvider', () => { - it('should throw error when not initialized', () => { - // Mock the controller as not initialized - (controller as any).isInitialized = false; + it('throws error when not initialized', () => { + controller.testSetInitialized(false); expect(() => controller.getActiveProvider()).toThrow( 'CLIENT_NOT_INITIALIZED', ); }); - it('should return provider when initialized', () => { + it('returns provider when initialized', () => { markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); const provider = controller.getActiveProvider(); expect(provider).toBe(mockProvider); @@ -700,21 +702,21 @@ describe('PerpsController', () => { }); describe('init', () => { - it('should initialize providers successfully', async () => { + it('initializes providers successfully', async () => { await controller.init(); - expect((controller as any).isInitialized).toBe(true); - expect((controller as any).providers.has('hyperliquid')).toBe(true); + expect(controller.testGetInitialized()).toBe(true); + expect(controller.testGetProviders().has('hyperliquid')).toBe(true); }); - it('should handle initialization when already initialized', async () => { + it('handles initialization when already initialized', async () => { // First initialization await controller.init(); - expect((controller as any).isInitialized).toBe(true); + expect(controller.testGetInitialized()).toBe(true); // Second initialization should not throw await controller.init(); - expect((controller as any).isInitialized).toBe(true); + expect(controller.testGetInitialized()).toBe(true); }); it('allows retry after all initialization attempts fail', async () => { @@ -726,7 +728,6 @@ describe('PerpsController', () => { throw networkError; }); - // Create new controller with failing provider mock const mockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { return { @@ -740,15 +741,8 @@ describe('PerpsController', () => { return undefined; }); - const testController = new PerpsController({ - messenger: { - call: mockCall, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), }); @@ -764,7 +758,7 @@ describe('PerpsController', () => { // Verify failure state expect(testController.state.initializationState).toBe('failed'); expect(testController.state.initializationError).toBe('Network error'); - expect((testController as any).isInitialized).toBe(false); + expect(testController.testGetInitialized()).toBe(false); // Network recovers - provider succeeds on next attempt ( @@ -777,12 +771,12 @@ describe('PerpsController', () => { // Verify initialization succeeds (not cached failure) expect(testController.state.initializationState).toBe('initialized'); expect(testController.state.initializationError).toBeNull(); - expect((testController as any).isInitialized).toBe(true); + expect(testController.testGetInitialized()).toBe(true); }); // Fast execution with mocked wait() }); describe('getPositions', () => { - it('should get positions successfully', async () => { + it('gets positions successfully', async () => { const mockPositions = [ { coin: 'ETH', @@ -806,29 +800,37 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getPositions.mockResolvedValue(mockPositions); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getPositions') + .mockResolvedValue(mockPositions); const result = await controller.getPositions(); expect(result).toEqual(mockPositions); - expect(mockProvider.getPositions).toHaveBeenCalled(); + expect(MarketDataService.getPositions).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); }); - it('should handle getPositions error', async () => { + it('handles getPositions error', async () => { const errorMessage = 'Network error'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getPositions.mockRejectedValue(new Error(errorMessage)); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); await expect(controller.getPositions()).rejects.toThrow(errorMessage); - expect(mockProvider.getPositions).toHaveBeenCalled(); + expect(MarketDataService.getPositions).toHaveBeenCalled(); }); }); describe('getAccountState', () => { - it('should get account state successfully', async () => { + it('gets account state successfully', async () => { const mockAccountState = { availableBalance: '1000', marginUsed: '500', @@ -838,18 +840,24 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getAccountState.mockResolvedValue(mockAccountState); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getAccountState') + .mockResolvedValue(mockAccountState); const result = await controller.getAccountState(); expect(result).toEqual(mockAccountState); - expect(mockProvider.getAccountState).toHaveBeenCalled(); + expect(MarketDataService.getAccountState).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); }); }); describe('placeOrder', () => { - it('should place order successfully', async () => { + it('places order successfully', async () => { const orderParams = { coin: 'BTC', isBuy: true, @@ -865,16 +873,24 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(TradingService, 'placeOrder') + .mockResolvedValue(mockOrderResult); const result = await controller.placeOrder(orderParams); expect(result).toEqual(mockOrderResult); - expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(TradingService.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: orderParams, + context: expect.any(Object), + }), + ); }); - it('should handle placeOrder error', async () => { + it('handles placeOrder error', async () => { const orderParams = { coin: 'BTC', isBuy: true, @@ -885,120 +901,20 @@ describe('PerpsController', () => { const errorMessage = 'Order placement failed'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.placeOrder.mockRejectedValue(new Error(errorMessage)); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(TradingService, 'placeOrder') + .mockRejectedValue(new Error(errorMessage)); await expect(controller.placeOrder(orderParams)).rejects.toThrow( errorMessage, ); - expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); - }); - - describe('fee discounts', () => { - it('should apply fee discount when placing order with rewards', async () => { - const orderParams = { - coin: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market' as const, - }; - - const mockOrderResult = { - success: true, - orderId: 'order-123', - filledSize: '0.1', - averagePrice: '50000', - }; - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.placeOrder.mockResolvedValue(mockOrderResult); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(6500); - - const result = await controller.placeOrder(orderParams); - - expect(result).toEqual(mockOrderResult); - expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); - expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); - expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('should clear fee discount context even when place order fails', async () => { - const orderParams = { - coin: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market' as const, - }; - - const mockError = new Error('Order placement failed'); - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.placeOrder.mockRejectedValue(mockError); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(6500); - - await expect(controller.placeOrder(orderParams)).rejects.toThrow( - 'Order placement failed', - ); - - expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); - expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('should place order without discount when user has no rewards', async () => { - const orderParams = { - coin: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market' as const, - }; - - const mockOrderResult = { - success: true, - orderId: 'order-123', - filledSize: '0.1', - averagePrice: '50000', - }; - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.placeOrder.mockResolvedValue(mockOrderResult); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(undefined); - - const result = await controller.placeOrder(orderParams); - - expect(result).toEqual(mockOrderResult); - expect(mockProvider.setUserFeeDiscount).not.toHaveBeenCalledWith(6500); - expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); - }); + expect(TradingService.placeOrder).toHaveBeenCalled(); }); }); describe('getMarkets', () => { - it('should get markets successfully', async () => { + it('gets markets successfully', async () => { const mockMarkets = [ { name: 'BTC', @@ -1015,18 +931,24 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getMarkets.mockResolvedValue(mockMarkets); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getMarkets') + .mockResolvedValue(mockMarkets); const result = await controller.getMarkets(); expect(result).toEqual(mockMarkets); - expect(mockProvider.getMarkets).toHaveBeenCalled(); + expect(MarketDataService.getMarkets).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); }); }); describe('cancelOrder', () => { - it('should cancel order successfully', async () => { + it('cancels order successfully', async () => { const cancelParams = { orderId: 'order-123', coin: 'BTC', @@ -1038,266 +960,81 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.cancelOrder.mockResolvedValue(mockCancelResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(TradingService, 'cancelOrder') + .mockResolvedValue(mockCancelResult); const result = await controller.cancelOrder(cancelParams); expect(result).toEqual(mockCancelResult); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith(cancelParams); + expect(TradingService.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: cancelParams, + context: expect.any(Object), + }), + ); }); }); describe('cancelOrders', () => { - beforeEach(() => { + it('delegates to TradingService with withStreamPause callback', async () => { markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - (controller as any).isCancelingOrders = false; - jest.clearAllMocks(); - }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const mockImplementation = jest.fn(async (options: any) => { + // Simulate TradingService calling the withStreamPause callback + await options.withStreamPause( + async () => ({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ coin: 'BTC', orderId: 'order-1', success: true }], + }), + ['orders'], + ); - it('cancels all orders when cancelAll is true', async () => { - const mockOrders = [ - createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), - createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); + return { + success: true, + successCount: 1, + failureCount: 0, + results: [{ coin: 'BTC', orderId: 'order-1', success: true }], + }; + }); - const result = await controller.cancelOrders({ cancelAll: true }); + jest + .spyOn(TradingService, 'cancelOrders') + .mockImplementation(mockImplementation); - expect(mockProvider.getOpenOrders).toHaveBeenCalled(); - expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); - expect(result.successCount).toBe(2); - expect(result.failureCount).toBe(0); - expect(result.success).toBe(true); - }); + await controller.cancelOrders({ cancelAll: true }); - it('excludes TP/SL orders when cancelAll is true', async () => { - const mockOrders = [ - createMockOrder({ - orderId: 'order-1', - symbol: 'BTC', - detailedOrderType: 'Limit', - }), - createMockOrder({ - orderId: 'order-2', - symbol: 'ETH', - detailedOrderType: 'Take Profit Limit', - }), - createMockOrder({ - orderId: 'order-3', - symbol: 'SOL', - detailedOrderType: 'Stop Market', - }), - createMockOrder({ - orderId: 'order-4', - symbol: 'BTC', - detailedOrderType: 'Limit', + expect(mockStreamManager.orders.pause).toHaveBeenCalled(); + expect(mockStreamManager.orders.resume).toHaveBeenCalled(); + expect(TradingService.cancelOrders).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { cancelAll: true }, + context: expect.any(Object), + withStreamPause: expect.any(Function), }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); - - const result = await controller.cancelOrders({ cancelAll: true }); - - expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'BTC', - orderId: 'order-1', - }); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'BTC', - orderId: 'order-4', - }); - expect(mockProvider.cancelOrder).not.toHaveBeenCalledWith({ - coin: 'ETH', - orderId: 'order-2', - }); - expect(mockProvider.cancelOrder).not.toHaveBeenCalledWith({ - coin: 'SOL', - orderId: 'order-3', - }); - expect(result.successCount).toBe(2); - expect(result.failureCount).toBe(0); - }); - - it('cancels all regular orders and excludes all TP/SL types', async () => { - const mockOrders = [ - createMockOrder({ - orderId: 'order-1', - symbol: 'BTC', - detailedOrderType: 'Limit', - }), - createMockOrder({ - orderId: 'order-2', - symbol: 'ETH', - detailedOrderType: 'Take Profit Limit', - }), - createMockOrder({ - orderId: 'order-3', - symbol: 'SOL', - detailedOrderType: 'Take Profit Market', - }), - createMockOrder({ - orderId: 'order-4', - symbol: 'AVAX', - detailedOrderType: 'Stop Limit', - }), - createMockOrder({ - orderId: 'order-5', - symbol: 'MATIC', - detailedOrderType: 'Stop Market', - }), - createMockOrder({ - orderId: 'order-6', - symbol: 'DOT', - detailedOrderType: 'Market', - }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); - - const result = await controller.cancelOrders({ cancelAll: true }); - - expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'BTC', - orderId: 'order-1', - }); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'DOT', - orderId: 'order-6', - }); - expect(result.successCount).toBe(2); - }); - - it('allows canceling TP/SL orders when specified by orderId', async () => { - const mockOrders = [ - createMockOrder({ - orderId: 'order-1', - symbol: 'BTC', - detailedOrderType: 'Limit', - }), - createMockOrder({ - orderId: 'order-2', - symbol: 'ETH', - detailedOrderType: 'Take Profit Limit', - }), - createMockOrder({ - orderId: 'order-3', - symbol: 'SOL', - detailedOrderType: 'Stop Market', - }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); - - const result = await controller.cancelOrders({ - orderIds: ['order-2', 'order-3'], - }); - - expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'ETH', - orderId: 'order-2', - }); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'SOL', - orderId: 'order-3', - }); - expect(result.successCount).toBe(2); - }); - - it('cancels specific order IDs when provided', async () => { - const mockOrders = [ - createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), - createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), - createMockOrder({ orderId: 'order-3', symbol: 'SOL' }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); - - const result = await controller.cancelOrders({ - orderIds: ['order-1', 'order-3'], - }); - - expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'BTC', - orderId: 'order-1', - }); - expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ - coin: 'SOL', - orderId: 'order-3', - }); - expect(result.successCount).toBe(2); - }); - - it('cancels orders for specific coins when provided', async () => { - const mockOrders = [ - createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), - createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), - createMockOrder({ orderId: 'order-3', symbol: 'BTC' }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); - - const result = await controller.cancelOrders({ coins: ['BTC'] }); - - expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); - expect(result.successCount).toBe(2); - }); - - it('returns empty results when no orders match filters', async () => { - const mockOrders = [ - createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - - const result = await controller.cancelOrders({ coins: ['ETH'] }); - - expect(mockProvider.cancelOrder).not.toHaveBeenCalled(); - expect(result).toEqual({ - success: false, - successCount: 0, - failureCount: 0, - results: [], - }); - }); - - it('handles partial failures gracefully', async () => { - const mockOrders = [ - createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), - createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: false, error: 'Network error' }); - - const result = await controller.cancelOrders({ cancelAll: true }); - - expect(result.successCount).toBe(1); - expect(result.failureCount).toBe(1); - expect(result.success).toBe(true); + ); }); - it('pauses and resumes streams during batch cancellation', async () => { - const mockOrders = [ - createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), - ]; - mockProvider.getOpenOrders.mockResolvedValue(mockOrders); - mockProvider.cancelOrder.mockResolvedValue({ success: true }); - - await controller.cancelOrders({ cancelAll: true }); + it('resumes streams even when operation throws error', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - expect(mockStreamManager.orders.pause).toHaveBeenCalled(); - expect(mockStreamManager.orders.resume).toHaveBeenCalled(); - }); + const mockImplementation = jest.fn(async (options: any) => + // Simulate TradingService calling the withStreamPause callback with an error + options.withStreamPause(async () => { + throw new Error('Network error'); + }, ['orders']), + ); - it('resumes streams even when operation throws error', async () => { - mockProvider.getOpenOrders.mockRejectedValue(new Error('Network error')); + jest + .spyOn(TradingService, 'cancelOrders') + .mockImplementation(mockImplementation); await expect( controller.cancelOrders({ cancelAll: true }), @@ -1309,7 +1046,7 @@ describe('PerpsController', () => { }); describe('closePosition', () => { - it('should close position successfully', async () => { + it('closes position successfully', async () => { const closeParams = { coin: 'BTC', orderType: 'market' as const, @@ -1324,193 +1061,52 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.closePosition.mockResolvedValue(mockCloseResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(TradingService, 'closePosition') + .mockResolvedValue(mockCloseResult); const result = await controller.closePosition(closeParams); expect(result).toEqual(mockCloseResult); - expect(mockProvider.closePosition).toHaveBeenCalledWith(closeParams); - }); - - describe('fee discounts', () => { - it('should apply fee discount when closing position with rewards', async () => { - const closeParams = { - coin: 'BTC', - orderType: 'market' as const, - size: '0.5', - }; - - const mockCloseResult = { - success: true, - orderId: 'close-order-123', - filledSize: '0.5', - averagePrice: '50000', - }; - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.closePosition.mockResolvedValue(mockCloseResult); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(6500); - - const result = await controller.closePosition(closeParams); - - expect(result).toEqual(mockCloseResult); - expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); - expect(mockProvider.closePosition).toHaveBeenCalledWith(closeParams); - expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('should clear fee discount context even when close position fails', async () => { - const closeParams = { - coin: 'BTC', - orderType: 'market' as const, - size: '0.5', - }; - - const mockError = new Error('Close position failed'); - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.closePosition.mockRejectedValue(mockError); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(6500); - - await expect(controller.closePosition(closeParams)).rejects.toThrow( - 'Close position failed', - ); - - expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); - expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('should close position without discount when user has no rewards', async () => { - const closeParams = { - coin: 'BTC', - orderType: 'market' as const, - size: '0.5', - }; - - const mockCloseResult = { - success: true, - orderId: 'close-order-123', - filledSize: '0.5', - averagePrice: '50000', - }; - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.closePosition.mockResolvedValue(mockCloseResult); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(undefined); - - const result = await controller.closePosition(closeParams); - - expect(result).toEqual(mockCloseResult); - expect(mockProvider.setUserFeeDiscount).not.toHaveBeenCalledWith(6500); - expect(mockProvider.closePosition).toHaveBeenCalledWith(closeParams); - }); + expect(TradingService.closePosition).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: closeParams, + context: expect.any(Object), + }), + ); }); }); describe('closePositions', () => { - beforeEach(() => { + it('delegates to TradingService.closePositions', async () => { markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - }); - - it('closes all positions when closeAll is true', async () => { - const mockPositions = [ - createMockPosition({ coin: 'BTC' }), - createMockPosition({ coin: 'ETH' }), - ]; - mockProvider.getPositions.mockResolvedValue(mockPositions); - mockProvider.closePosition.mockResolvedValue({ success: true }); - - const result = await controller.closePositions({ closeAll: true }); - - expect(mockProvider.getPositions).toHaveBeenCalled(); - expect(mockProvider.closePosition).toHaveBeenCalledTimes(2); - expect(result.successCount).toBe(2); - expect(result.failureCount).toBe(0); - expect(result.success).toBe(true); - }); - - it('closes specific coins when provided', async () => { - const mockPositions = [ - createMockPosition({ coin: 'BTC' }), - createMockPosition({ coin: 'ETH' }), - createMockPosition({ coin: 'SOL' }), - ]; - mockProvider.getPositions.mockResolvedValue(mockPositions); - mockProvider.closePosition.mockResolvedValue({ success: true }); - - const result = await controller.closePositions({ coins: ['BTC', 'SOL'] }); - - expect(mockProvider.closePosition).toHaveBeenCalledTimes(2); - expect(mockProvider.closePosition).toHaveBeenCalledWith({ coin: 'BTC' }); - expect(mockProvider.closePosition).toHaveBeenCalledWith({ coin: 'SOL' }); - expect(result.successCount).toBe(2); - }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - it('returns empty results when no positions match', async () => { - const mockPositions = [createMockPosition({ coin: 'BTC' })]; - mockProvider.getPositions.mockResolvedValue(mockPositions); - - const result = await controller.closePositions({ coins: ['ETH'] }); - - expect(mockProvider.closePosition).not.toHaveBeenCalled(); - expect(result).toEqual({ - success: false, - successCount: 0, + jest.spyOn(TradingService, 'closePositions').mockResolvedValue({ + success: true, + successCount: 1, failureCount: 0, - results: [], + results: [{ coin: 'BTC', success: true }], }); - }); - - it('handles partial failures gracefully', async () => { - const mockPositions = [ - createMockPosition({ coin: 'BTC' }), - createMockPosition({ coin: 'ETH' }), - ]; - mockProvider.getPositions.mockResolvedValue(mockPositions); - mockProvider.closePosition - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ - success: false, - error: 'Insufficient margin', - }); const result = await controller.closePositions({ closeAll: true }); - expect(result.successCount).toBe(1); - expect(result.failureCount).toBe(1); expect(result.success).toBe(true); + expect(result.successCount).toBe(1); + expect(TradingService.closePositions).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { closeAll: true }, + context: expect.any(Object), + }), + ); }); }); describe('validateOrder', () => { - it('should validate order successfully', async () => { + it('validates order successfully', async () => { const orderParams = { coin: 'BTC', isBuy: true, @@ -1523,18 +1119,23 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.validateOrder.mockResolvedValue(mockValidationResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'validateOrder') + .mockResolvedValue(mockValidationResult); const result = await controller.validateOrder(orderParams); expect(result).toEqual(mockValidationResult); - expect(mockProvider.validateOrder).toHaveBeenCalledWith(orderParams); + expect(MarketDataService.validateOrder).toHaveBeenCalledWith({ + provider: mockProvider, + params: orderParams, + }); }); }); describe('getOrderFills', () => { - it('should get order fills successfully', async () => { + it('gets order fills successfully', async () => { const mockOrderFills = [ { orderId: 'order-123', @@ -1551,18 +1152,24 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getOrderFills.mockResolvedValue(mockOrderFills); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getOrderFills') + .mockResolvedValue(mockOrderFills); const result = await controller.getOrderFills(); expect(result).toEqual(mockOrderFills); - expect(mockProvider.getOrderFills).toHaveBeenCalled(); + expect(MarketDataService.getOrderFills).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); }); }); describe('getOrders', () => { - it('should get orders successfully', async () => { + it('gets orders successfully', async () => { const mockOrders = [ { orderId: 'order-123', @@ -1580,18 +1187,22 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getOrders.mockResolvedValue(mockOrders); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest.spyOn(MarketDataService, 'getOrders').mockResolvedValue(mockOrders); const result = await controller.getOrders(); expect(result).toEqual(mockOrders); - expect(mockProvider.getOrders).toHaveBeenCalled(); + expect(MarketDataService.getOrders).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); }); }); describe('subscribeToPrices', () => { - it('should subscribe to price updates', () => { + it('subscribes to price updates', () => { const mockUnsubscribe = jest.fn(); const params = { symbols: ['BTC', 'ETH'], @@ -1599,7 +1210,7 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.subscribeToPrices.mockReturnValue(mockUnsubscribe); const unsubscribe = controller.subscribeToPrices(params); @@ -1610,14 +1221,14 @@ describe('PerpsController', () => { }); describe('subscribeToPositions', () => { - it('should subscribe to position updates', () => { + it('subscribes to position updates', () => { const mockUnsubscribe = jest.fn(); const params = { callback: jest.fn(), }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.subscribeToPositions.mockReturnValue(mockUnsubscribe); const unsubscribe = controller.subscribeToPositions(params); @@ -1628,7 +1239,7 @@ describe('PerpsController', () => { }); describe('withdraw', () => { - it('should withdraw successfully', async () => { + it('withdraws successfully', async () => { const withdrawParams = { amount: '100', destination: @@ -1644,18 +1255,30 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.withdraw.mockResolvedValue(mockWithdrawResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(AccountService, 'withdraw') + .mockResolvedValue(mockWithdrawResult); const result = await controller.withdraw(withdrawParams); expect(result).toEqual(mockWithdrawResult); - expect(mockProvider.withdraw).toHaveBeenCalledWith(withdrawParams); + expect(AccountService.withdraw).toHaveBeenCalledWith({ + provider: mockProvider, + params: withdrawParams, + context: expect.objectContaining({ + tracingContext: expect.any(Object), + analytics: expect.any(Object), + errorContext: expect.objectContaining({ method: 'withdraw' }), + stateManager: expect.any(Object), + }), + refreshAccountState: expect.any(Function), + }); }); }); describe('calculateLiquidationPrice', () => { - it('should calculate liquidation price successfully', async () => { + it('calculates liquidation price successfully', async () => { const liquidationParams = { entryPrice: 50000, leverage: 10, @@ -1668,39 +1291,45 @@ describe('PerpsController', () => { const mockLiquidationPrice = '45000'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.calculateLiquidationPrice.mockResolvedValue( - mockLiquidationPrice, - ); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'calculateLiquidationPrice') + .mockResolvedValue(mockLiquidationPrice); const result = await controller.calculateLiquidationPrice(liquidationParams); expect(result).toBe(mockLiquidationPrice); - expect(mockProvider.calculateLiquidationPrice).toHaveBeenCalledWith( - liquidationParams, - ); + expect(MarketDataService.calculateLiquidationPrice).toHaveBeenCalledWith({ + provider: mockProvider, + params: liquidationParams, + }); }); }); describe('getMaxLeverage', () => { - it('should get max leverage successfully', async () => { + it('gets max leverage successfully', async () => { const asset = 'BTC'; const mockMaxLeverage = 50; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getMaxLeverage.mockResolvedValue(mockMaxLeverage); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getMaxLeverage') + .mockResolvedValue(mockMaxLeverage); const result = await controller.getMaxLeverage(asset); expect(result).toBe(mockMaxLeverage); - expect(mockProvider.getMaxLeverage).toHaveBeenCalledWith(asset); + expect(MarketDataService.getMaxLeverage).toHaveBeenCalledWith({ + provider: mockProvider, + asset, + }); }); }); describe('getWithdrawalRoutes', () => { - it('should get withdrawal routes successfully', () => { + it('gets withdrawal routes successfully', () => { const mockRoutes = [ { assetId: @@ -1716,59 +1345,72 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getWithdrawalRoutes.mockReturnValue(mockRoutes); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getWithdrawalRoutes') + .mockReturnValue(mockRoutes); const result = controller.getWithdrawalRoutes(); expect(result).toEqual(mockRoutes); - expect(mockProvider.getWithdrawalRoutes).toHaveBeenCalled(); + expect(MarketDataService.getWithdrawalRoutes).toHaveBeenCalledWith({ + provider: mockProvider, + }); }); }); describe('getBlockExplorerUrl', () => { - it('should get block explorer URL successfully', () => { + it('gets block explorer URL successfully', () => { const address = '0x1234567890123456789012345678901234567890'; const mockUrl = 'https://app.hyperliquid.xyz/explorer/address/0x1234567890123456789012345678901234567890'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getBlockExplorerUrl.mockReturnValue(mockUrl); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getBlockExplorerUrl') + .mockReturnValue(mockUrl); const result = controller.getBlockExplorerUrl(address); expect(result).toBe(mockUrl); - expect(mockProvider.getBlockExplorerUrl).toHaveBeenCalledWith(address); + expect(MarketDataService.getBlockExplorerUrl).toHaveBeenCalledWith({ + provider: mockProvider, + address, + }); }); }); describe('error handling', () => { - it('should handle provider errors gracefully', async () => { + it('handles provider errors gracefully', async () => { const errorMessage = 'Provider connection failed'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getPositions.mockRejectedValue(new Error(errorMessage)); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); await expect(controller.getPositions()).rejects.toThrow(errorMessage); - expect(mockProvider.getPositions).toHaveBeenCalled(); + expect(MarketDataService.getPositions).toHaveBeenCalled(); }); - it('should handle network errors', async () => { + it('handles network errors', async () => { const errorMessage = 'Network timeout'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getAccountState.mockRejectedValue(new Error(errorMessage)); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getAccountState') + .mockRejectedValue(new Error(errorMessage)); await expect(controller.getAccountState()).rejects.toThrow(errorMessage); - expect(mockProvider.getAccountState).toHaveBeenCalled(); + expect(MarketDataService.getAccountState).toHaveBeenCalled(); }); }); describe('state management', () => { - it('should return positions without updating state', async () => { + it('returns positions without updating state', async () => { const mockPositions = [ { coin: 'ETH', @@ -1792,32 +1434,35 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getPositions.mockResolvedValue(mockPositions); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getPositions') + .mockResolvedValue(mockPositions); const result = await controller.getPositions(); expect(result).toEqual(mockPositions); - expect(mockProvider.getPositions).toHaveBeenCalled(); - // Note: getPositions doesn't update controller state, it just returns data + expect(MarketDataService.getPositions).toHaveBeenCalled(); }); - it('should handle errors without updating state', async () => { + it('handles errors without updating state', async () => { const errorMessage = 'Failed to fetch positions'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getPositions.mockRejectedValue(new Error(errorMessage)); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); await expect(controller.getPositions()).rejects.toThrow(errorMessage); - expect(mockProvider.getPositions).toHaveBeenCalled(); + expect(MarketDataService.getPositions).toHaveBeenCalled(); }); }); describe('connection management', () => { - it('should handle disconnection', async () => { + it('handles disconnection', async () => { markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.disconnect.mockResolvedValue({ success: true }); await controller.disconnect(); @@ -1826,7 +1471,7 @@ describe('PerpsController', () => { expect(controller.state.connectionStatus).toBe('disconnected'); }); - it('should handle connection status from state', () => { + it('handles connection status from state', () => { // Test that we can access connection status from controller state expect(controller.state.connectionStatus).toBe('disconnected'); @@ -1839,7 +1484,7 @@ describe('PerpsController', () => { }); describe('utility methods', () => { - it('should get funding information', async () => { + it('gets funding information', async () => { const mockFunding = [ { symbol: 'BTC', @@ -1851,16 +1496,22 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getFunding.mockResolvedValue(mockFunding); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getFunding') + .mockResolvedValue(mockFunding); const result = await controller.getFunding(); expect(result).toEqual(mockFunding); - expect(mockProvider.getFunding).toHaveBeenCalled(); + expect(MarketDataService.getFunding).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); }); - it('should get order fills with parameters', async () => { + it('gets order fills with parameters', async () => { const params = { limit: 10, user: '0x123' as `0x${string}` }; const mockOrderFills = [ { @@ -1878,18 +1529,24 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getOrderFills.mockResolvedValue(mockOrderFills); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getOrderFills') + .mockResolvedValue(mockOrderFills); const result = await controller.getOrderFills(params); expect(result).toEqual(mockOrderFills); - expect(mockProvider.getOrderFills).toHaveBeenCalledWith(params); + expect(MarketDataService.getOrderFills).toHaveBeenCalledWith({ + provider: mockProvider, + params, + context: expect.any(Object), + }); }); }); describe('order management', () => { - it('should edit order successfully', async () => { + it('edits order successfully', async () => { const editParams = { orderId: 'order-123', newOrder: { @@ -1908,16 +1565,22 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.editOrder.mockResolvedValue(mockEditResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest.spyOn(TradingService, 'editOrder').mockResolvedValue(mockEditResult); const result = await controller.editOrder(editParams); expect(result).toEqual(mockEditResult); - expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(TradingService.editOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: editParams, + context: expect.any(Object), + }), + ); }); - it('should handle edit order error', async () => { + it('handles edit order error', async () => { const editParams = { orderId: 'order-123', newOrder: { @@ -1932,25 +1595,27 @@ describe('PerpsController', () => { const errorMessage = 'Order edit failed'; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.editOrder.mockRejectedValue(new Error(errorMessage)); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(TradingService, 'editOrder') + .mockRejectedValue(new Error(errorMessage)); await expect(controller.editOrder(editParams)).rejects.toThrow( errorMessage, ); - expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(TradingService.editOrder).toHaveBeenCalled(); }); }); describe('subscription management', () => { - it('should subscribe to order fills', () => { + it('subscribes to order fills', () => { const mockUnsubscribe = jest.fn(); const params = { callback: jest.fn(), }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.subscribeToOrderFills.mockReturnValue(mockUnsubscribe); const unsubscribe = controller.subscribeToOrderFills(params); @@ -1959,14 +1624,14 @@ describe('PerpsController', () => { expect(mockProvider.subscribeToOrderFills).toHaveBeenCalledWith(params); }); - it('should set live data configuration', () => { + it('sets live data configuration', () => { const config = { priceThrottleMs: 1000, positionThrottleMs: 2000, }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.setLiveDataConfig.mockReturnValue(undefined); controller.setLiveDataConfig(config); @@ -1974,7 +1639,7 @@ describe('PerpsController', () => { expect(mockProvider.setLiveDataConfig).toHaveBeenCalledWith(config); }); - it('should handle subscription cleanup', () => { + it('handles subscription cleanup', () => { const mockUnsubscribe = jest.fn(); const params = { symbols: ['BTC', 'ETH'], @@ -1982,7 +1647,7 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.subscribeToPrices.mockReturnValue(mockUnsubscribe); const unsubscribe = controller.subscribeToPrices(params); @@ -1994,7 +1659,7 @@ describe('PerpsController', () => { }); describe('deposit operations', () => { - it('should clear deposit result', () => { + it('clears deposit result', () => { // Test that clearDepositResult method exists and can be called expect(() => controller.clearDepositResult()).not.toThrow(); @@ -2004,7 +1669,7 @@ describe('PerpsController', () => { }); describe('withdrawal operations', () => { - it('should clear withdraw result', () => { + it('clears withdraw result', () => { // Test that clearWithdrawResult method exists and can be called expect(() => controller.clearWithdrawResult()).not.toThrow(); @@ -2014,14 +1679,14 @@ describe('PerpsController', () => { }); describe('network management', () => { - it('should get current network', () => { + it('gets current network', () => { const network = controller.getCurrentNetwork(); expect(['mainnet', 'testnet']).toContain(network); expect(typeof network).toBe('string'); }); - it('should get withdrawal routes', () => { + it('gets withdrawal routes', () => { const mockRoutes = [ { assetId: @@ -2037,24 +1702,28 @@ describe('PerpsController', () => { ]; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.getWithdrawalRoutes.mockReturnValue(mockRoutes); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'getWithdrawalRoutes') + .mockReturnValue(mockRoutes); const result = controller.getWithdrawalRoutes(); expect(result).toEqual(mockRoutes); - expect(mockProvider.getWithdrawalRoutes).toHaveBeenCalled(); + expect(MarketDataService.getWithdrawalRoutes).toHaveBeenCalledWith({ + provider: mockProvider, + }); }); }); describe('user management', () => { - it('should check if first time user on current network', () => { + it('checks if first time user on current network', () => { const isFirstTime = controller.isFirstTimeUserOnCurrentNetwork(); expect(typeof isFirstTime).toBe('boolean'); }); - it('should mark tutorial as completed', () => { + it('marks tutorial as completed', () => { // Test that markTutorialCompleted method exists and can be called expect(() => controller.markTutorialCompleted()).not.toThrow(); @@ -2064,12 +1733,12 @@ describe('PerpsController', () => { }); describe('watchlist markets', () => { - it('should return empty array by default', () => { + it('returns empty array by default', () => { const watchlist = controller.getWatchlistMarkets(); expect(watchlist).toEqual([]); }); - it('should toggle watchlist market (add)', () => { + it('toggles watchlist market (add)', () => { controller.toggleWatchlistMarket('BTC'); const watchlist = controller.getWatchlistMarkets(); @@ -2077,7 +1746,7 @@ describe('PerpsController', () => { expect(controller.isWatchlistMarket('BTC')).toBe(true); }); - it('should toggle watchlist market (remove)', () => { + it('toggles watchlist market (remove)', () => { controller.toggleWatchlistMarket('BTC'); controller.toggleWatchlistMarket('BTC'); @@ -2086,7 +1755,7 @@ describe('PerpsController', () => { expect(controller.isWatchlistMarket('BTC')).toBe(false); }); - it('should handle multiple watchlist markets', () => { + it('handles multiple watchlist markets', () => { controller.toggleWatchlistMarket('BTC'); controller.toggleWatchlistMarket('ETH'); controller.toggleWatchlistMarket('SOL'); @@ -2098,9 +1767,9 @@ describe('PerpsController', () => { expect(watchlist).toContain('SOL'); }); - it('should persist watchlist per network', () => { + it('persist watchlist per network', () => { // Add to watchlist on mainnet (default is testnet in dev, so set to false) - (controller as any).update((state: any) => { + controller.testUpdate((state) => { state.isTestnet = false; }); controller.toggleWatchlistMarket('BTC'); @@ -2109,7 +1778,7 @@ describe('PerpsController', () => { expect(mainnetWatchlist).toContain('BTC'); // Switch to testnet - (controller as any).update((state: any) => { + controller.testUpdate((state) => { state.isTestnet = true; }); const testnetWatchlist = controller.getWatchlistMarkets(); @@ -2121,7 +1790,7 @@ describe('PerpsController', () => { expect(controller.isWatchlistMarket('ETH')).toBe(true); // Switch back to mainnet - (controller as any).update((state: any) => { + controller.testUpdate((state) => { state.isTestnet = false; }); expect(controller.getWatchlistMarkets()).toContain('BTC'); @@ -2130,14 +1799,14 @@ describe('PerpsController', () => { }); describe('additional subscriptions', () => { - it('should subscribe to orders', () => { + it('subscribes to orders', () => { const mockUnsubscribe = jest.fn(); const params = { callback: jest.fn(), }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.subscribeToOrders.mockReturnValue(mockUnsubscribe); const unsubscribe = controller.subscribeToOrders(params); @@ -2146,14 +1815,14 @@ describe('PerpsController', () => { expect(mockProvider.subscribeToOrders).toHaveBeenCalledWith(params); }); - it('should subscribe to account updates', () => { + it('subscribes to account updates', () => { const mockUnsubscribe = jest.fn(); const params = { callback: jest.fn(), }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockProvider.subscribeToAccount.mockReturnValue(mockUnsubscribe); const unsubscribe = controller.subscribeToAccount(params); @@ -2164,7 +1833,7 @@ describe('PerpsController', () => { }); describe('validation methods', () => { - it('should validate close position', async () => { + it('validates close position', async () => { const closeParams = { coin: 'BTC', orderType: 'market' as const, @@ -2177,20 +1846,21 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.validateClosePosition.mockResolvedValue( - mockValidationResult, - ); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'validateClosePosition') + .mockResolvedValue(mockValidationResult); const result = await controller.validateClosePosition(closeParams); expect(result).toEqual(mockValidationResult); - expect(mockProvider.validateClosePosition).toHaveBeenCalledWith( - closeParams, - ); + expect(MarketDataService.validateClosePosition).toHaveBeenCalledWith({ + provider: mockProvider, + params: closeParams, + }); }); - it('should validate withdrawal', async () => { + it('validates withdrawal', async () => { const withdrawParams = { amount: '100', destination: @@ -2203,20 +1873,23 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.validateWithdrawal.mockResolvedValue(mockValidationResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(AccountService, 'validateWithdrawal') + .mockResolvedValue(mockValidationResult); const result = await controller.validateWithdrawal(withdrawParams); expect(result).toEqual(mockValidationResult); - expect(mockProvider.validateWithdrawal).toHaveBeenCalledWith( - withdrawParams, - ); + expect(AccountService.validateWithdrawal).toHaveBeenCalledWith({ + provider: mockProvider, + params: withdrawParams, + }); }); }); describe('position management', () => { - it('should update position TP/SL', async () => { + it('updates position TP/SL', async () => { const updateParams = { coin: 'BTC', takeProfitPrice: '55000', @@ -2229,117 +1902,24 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.updatePositionTPSL.mockResolvedValue(mockUpdateResult); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(TradingService, 'updatePositionTPSL') + .mockResolvedValue(mockUpdateResult); const result = await controller.updatePositionTPSL(updateParams); expect(result).toEqual(mockUpdateResult); - expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith( - updateParams, + expect(TradingService.updatePositionTPSL).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: updateParams, + context: expect.any(Object), + }), ); }); - describe('TP/SL fee discounts', () => { - it('should apply fee discount when updating TP/SL with rewards', async () => { - const updateParams = { - coin: 'BTC', - takeProfitPrice: '55000', - stopLossPrice: '45000', - }; - - const mockUpdateResult = { - success: true, - positionId: 'pos-123', - }; - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.updatePositionTPSL.mockResolvedValue(mockUpdateResult); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(6500); - - const result = await controller.updatePositionTPSL(updateParams); - - expect(result).toEqual(mockUpdateResult); - expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); - expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith( - updateParams, - ); - expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('should clear fee discount context even when TP/SL update fails', async () => { - const updateParams = { - coin: 'BTC', - takeProfitPrice: '55000', - stopLossPrice: '45000', - }; - - const mockError = new Error('TP/SL update failed'); - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.updatePositionTPSL.mockRejectedValue(mockError); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(6500); - - await expect( - controller.updatePositionTPSL(updateParams), - ).rejects.toThrow('TP/SL update failed'); - - expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); - expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('should update TP/SL without discount when user has no rewards', async () => { - const updateParams = { - coin: 'BTC', - takeProfitPrice: '55000', - stopLossPrice: '45000', - }; - - const mockUpdateResult = { - success: true, - positionId: 'pos-123', - }; - - markControllerAsInitialized(); - (controller as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); - mockProvider.updatePositionTPSL.mockResolvedValue(mockUpdateResult); - mockProvider.setUserFeeDiscount = jest.fn(); - - jest - .spyOn(controller as any, 'calculateUserFeeDiscount') - .mockResolvedValue(undefined); - - const result = await controller.updatePositionTPSL(updateParams); - - expect(result).toEqual(mockUpdateResult); - expect(mockProvider.setUserFeeDiscount).not.toHaveBeenCalledWith(6500); - expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith( - updateParams, - ); - }); - }); - - it('should calculate maintenance margin', async () => { + it('calculates maintenance margin', async () => { const marginParams = { coin: 'BTC', size: '1.0', @@ -2350,20 +1930,25 @@ describe('PerpsController', () => { const mockMargin = 2500; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.calculateMaintenanceMargin.mockResolvedValue(mockMargin); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'calculateMaintenanceMargin') + .mockResolvedValue(mockMargin); const result = await controller.calculateMaintenanceMargin(marginParams); expect(result).toBe(mockMargin); - expect(mockProvider.calculateMaintenanceMargin).toHaveBeenCalledWith( - marginParams, + expect(MarketDataService.calculateMaintenanceMargin).toHaveBeenCalledWith( + { + provider: mockProvider, + params: marginParams, + }, ); }); }); describe('fee calculations', () => { - it('should calculate fees', async () => { + it('calculates fees', async () => { const feeParams = { orderType: 'market' as const, isMaker: false, @@ -2383,197 +1968,734 @@ describe('PerpsController', () => { }; markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - mockProvider.calculateFees.mockResolvedValue(mockFees); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(MarketDataService, 'calculateFees') + .mockResolvedValue(mockFees); const result = await controller.calculateFees(feeParams); expect(result).toEqual(mockFees); - expect(mockProvider.calculateFees).toHaveBeenCalledWith(feeParams); + expect(MarketDataService.calculateFees).toHaveBeenCalledWith({ + provider: mockProvider, + params: feeParams, + }); }); }); describe('reportOrderToDataLake', () => { beforeEach(() => { - // Mock fetch globally - global.fetch = jest.fn(); - // Initialize controller markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); - }); - - afterEach(() => { - jest.restoreAllMocks(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); }); - it('should skip data lake reporting for testnet', async () => { - // Arrange - create a new controller with testnet state - const mockCallTestnet = jest.fn().mockImplementation((action: string) => { - if (action === 'RemoteFeatureFlagController:getState') { - return { - remoteFeatureFlags: { - perpsPerpTradingGeoBlockedCountriesV2: { - blockedRegions: [], - }, - }, - }; - } - return undefined; - }); - - const testnetController = new PerpsController({ - messenger: { - call: mockCallTestnet, - publish: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerEventHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - } as unknown as any, - state: { ...getDefaultPerpsControllerState(), isTestnet: true }, - }); - - // Initialize providers for testnet controller - (testnetController as any).isInitialized = true; - (testnetController as any).update((state: any) => { - state.initializationState = 'initialized'; - }); - (testnetController as any).providers = new Map([ - ['hyperliquid', mockProvider], - ]); + it('delegates to DataLakeService.reportOrder', async () => { + const mockReportResult = { + success: true, + error: undefined, + }; - // Clear any fetch calls from controller initialization - (global.fetch as jest.Mock).mockClear(); + jest + .spyOn(DataLakeService, 'reportOrder') + .mockResolvedValue(mockReportResult); - const result = await (testnetController as any).reportOrderToDataLake({ - action: 'open', + const orderParams = { + action: 'open' as const, coin: 'BTC', - }); + sl_price: 45000, + tp_price: 55000, + }; - expect(result.success).toBe(true); - expect(result.error).toBe('Skipped for testnet'); - expect(global.fetch).not.toHaveBeenCalled(); + const result = await controller.testReportOrderToDataLake(orderParams); + + expect(result).toEqual(mockReportResult); + expect(DataLakeService.reportOrder).toHaveBeenCalledWith({ + action: orderParams.action, + coin: orderParams.coin, + sl_price: orderParams.sl_price, + tp_price: orderParams.tp_price, + isTestnet: controller.state.isTestnet, + context: expect.objectContaining({ + tracingContext: expect.any(Object), + analytics: expect.any(Object), + errorContext: expect.objectContaining({ + method: 'reportOrderToDataLake', + }), + stateManager: expect.any(Object), + }), + retryCount: undefined, + _traceId: undefined, + }); }); }); - describe('placeOrder data lake error handling', () => { + describe('getAvailableDexs', () => { beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(controller, 'getActiveProvider').mockReturnValue(mockProvider); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); }); - it('handles data lake reporting errors gracefully', async () => { - markControllerAsInitialized(); - (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + it('returns available HIP-3 DEXs from provider', async () => { + const mockDexs = ['dex1', 'dex2', 'dex3']; + jest + .spyOn(MarketDataService, 'getAvailableDexs') + .mockResolvedValue(mockDexs); - mockProvider.placeOrder.mockResolvedValue({ - success: true, - orderId: 'order123', + const result = await controller.getAvailableDexs(); + + expect(result).toEqual(mockDexs); + expect(MarketDataService.getAvailableDexs).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, }); + }); - // Mock fetch to reject for data lake reporting - global.fetch = jest + it('passes filter parameters to provider', async () => { + const mockDexs = ['dex1']; + const filterParams = { validated: true }; + jest + .spyOn(MarketDataService, 'getAvailableDexs') + .mockResolvedValue(mockDexs); + + const result = await controller.getAvailableDexs(filterParams); + + expect(result).toEqual(mockDexs); + expect(MarketDataService.getAvailableDexs).toHaveBeenCalledWith({ + provider: mockProvider, + params: filterParams, + }); + }); + + it('throws error when provider does not support HIP-3', async () => { + jest + .spyOn(MarketDataService, 'getAvailableDexs') + .mockRejectedValue(new Error('Provider does not support HIP-3 DEXs')); + + await expect(controller.getAvailableDexs()).rejects.toThrow( + 'Provider does not support HIP-3 DEXs', + ); + }); + }); + + describe('depositWithConfirmation', () => { + const mockTransaction = { + from: '0x1234567890123456789012345678901234567890', + to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + value: '0x0', + data: '0x', + }; + + const mockDepositId = 'deposit-123'; + const mockAssetChainId = '0x1'; + const mockNetworkClientId = 'mainnet'; + const mockTransactionMeta = { id: 'tx-meta-123' }; + const mockTxHash = '0xhash123'; + + beforeEach(() => { + // Mock DepositService + jest.spyOn(DepositService, 'prepareTransaction').mockResolvedValue({ + transaction: mockTransaction, + assetChainId: mockAssetChainId, + currentDepositId: mockDepositId, + }); + + // Mock NetworkController + Engine.context.NetworkController.findNetworkClientIdByChainId = jest .fn() - .mockRejectedValueOnce(new Error('Network error')); + .mockReturnValue(mockNetworkClientId); - const orderParams = { - coin: 'BTC', - isBuy: true, - orderType: 'market' as const, - size: '1', - }; + // Mock TransactionController with promise-based result + Engine.context.TransactionController.addTransaction = jest + .fn() + .mockResolvedValue({ + result: Promise.resolve(mockTxHash), + transactionMeta: mockTransactionMeta, + }); + }); - const result = await controller.placeOrder(orderParams); + afterEach(() => { + // Clean up mock properties added in beforeEach to prevent test pollution + delete (Engine.context.NetworkController as any) + .findNetworkClientIdByChainId; + delete (Engine.context.TransactionController as any).addTransaction; + jest.clearAllMocks(); + }); - // Wait for async data lake reporting to complete - await new Promise((resolve) => setTimeout(resolve, 100)); + it('returns promise result', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - // Assert: Order should still succeed even if data lake reporting fails - expect(result.success).toBe(true); - expect(result.orderId).toBe('order123'); - - // Verify that Logger.error was called for the data lake failure - // The new implementation uses LoggerErrorOptions format - const errorCalls = (Logger.error as jest.Mock).mock.calls; - - const hasDataLakeError = errorCalls.some((call) => { - const secondArg = call[1]; - return ( - typeof secondArg === 'object' && - secondArg.context?.name === 'PerpsController' && - secondArg.context?.data?.method === 'reportOrderToDataLake' && - secondArg.context?.data?.coin === 'BTC' && - secondArg.context?.data?.action === 'open' - ); + const result = await controller.depositWithConfirmation('100'); + + expect(result).toEqual({ + result: expect.any(Promise), + }); + }); + + it('delegates to DepositService.prepareTransaction', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect(DepositService.prepareTransaction).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + + it('calls NetworkController.findNetworkClientIdByChainId with correct chainId', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect( + Engine.context.NetworkController.findNetworkClientIdByChainId, + ).toHaveBeenCalledWith(mockAssetChainId); + }); + + it('calls TransactionController.addTransaction with prepared transaction', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect( + Engine.context.TransactionController.addTransaction, + ).toHaveBeenCalledWith(mockTransaction, { + networkClientId: mockNetworkClientId, + origin: 'metamask', + type: 'perpsDeposit', }); - expect(hasDataLakeError).toBe(true); + }); + + it('throws error when controller not initialized', async () => { + controller.testSetInitialized(false); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('throws error when no active provider', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map()); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow(); + }); + + it('propagates DepositService errors', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const mockError = new Error('Deposit service failed'); + jest + .spyOn(DepositService, 'prepareTransaction') + .mockRejectedValue(mockError); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow( + 'Deposit service failed', + ); + }); + + it('propagates NetworkController errors', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const mockError = new Error('Network client not found'); + Engine.context.NetworkController.findNetworkClientIdByChainId = jest + .fn() + .mockImplementation(() => { + throw mockError; + }); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow( + 'Network client not found', + ); + }); + + it('propagates TransactionController errors', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const mockError = new Error('Transaction failed'); + Engine.context.TransactionController.addTransaction = jest + .fn() + .mockRejectedValue(mockError); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow( + 'Transaction failed', + ); + }); + + it('clears transaction ID when error occurs and not user cancellation', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.lastDepositTransactionId = 'old-tx-id'; + }); + const mockError = new Error('Network error'); + Engine.context.TransactionController.addTransaction = jest + .fn() + .mockRejectedValue(mockError); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow( + 'Network error', + ); + + expect(controller.state.lastDepositTransactionId).toBeNull(); + }); + + it('preserves state when user cancels transaction', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.lastDepositTransactionId = 'old-tx-id'; + }); + const mockError = new Error('User denied transaction signature'); + Engine.context.TransactionController.addTransaction = jest + .fn() + .mockRejectedValue(mockError); + + await expect(controller.depositWithConfirmation('100')).rejects.toThrow( + 'User denied', + ); + + // When user cancels, transaction ID is not cleared + expect(controller.state.lastDepositTransactionId).toBe('old-tx-id'); + }); + + it('clears stale deposit results before transaction', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.lastDepositResult = { + success: true, + txHash: '0xold', + amount: '50', + asset: 'USDC', + timestamp: Date.now() - 1000, + error: '', + }; + }); + + const { result } = await controller.depositWithConfirmation('100'); + + await result; + + // After promise resolves, lastDepositResult is set with new result + expect(controller.state.lastDepositResult).toBeTruthy(); + expect(controller.state.lastDepositResult?.success).toBe(true); + }); + + it('updates state with transaction details', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect(controller.state.lastDepositTransactionId).toBe('tx-meta-123'); + }); + + it('stores depositId from service immediately', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect(controller.state.depositRequests[0].id).toBe(mockDepositId); + }); + + it('delegates to DepositService with provider', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect(DepositService.prepareTransaction).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + + it('adds deposit request to tracking initially as pending', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation('100'); + + expect(controller.state.depositRequests).toHaveLength(1); + expect(controller.state.depositRequests[0].id).toBe(mockDepositId); + expect(controller.state.depositRequests[0].amount).toBe('100'); + expect(controller.state.depositRequests[0].asset).toBe('USDC'); + }); + + it('uses default amount when not provided', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.depositWithConfirmation(); + + expect(controller.state.depositRequests[0].amount).toBe('0'); + }); + + it('updates deposit request to completed when transaction succeeds', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const { result } = await controller.depositWithConfirmation('100'); + + await result; + + // After promise resolves, deposit request is marked as completed + expect(controller.state.depositRequests[0].status).toBe('completed'); + expect(controller.state.depositRequests[0].success).toBe(true); + expect(controller.state.depositRequests[0].txHash).toBe(mockTxHash); + }); + + it('handles concurrent deposit operations without data corruption', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const deposit1 = controller.depositWithConfirmation('100'); + const deposit2 = controller.depositWithConfirmation('200'); + + await Promise.all([deposit1, deposit2]); + + expect(controller.state.depositRequests).toHaveLength(2); + const amounts = controller.state.depositRequests.map((req) => req.amount); + expect(amounts).toContain('100'); + expect(amounts).toContain('200'); }); }); - describe('editOrder failure tracking', () => { + describe('updateWithdrawalStatus', () => { + const mockWithdrawalId = 'withdrawal-123'; + const mockTxHash = '0xhash456'; + beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(controller, 'getActiveProvider').mockReturnValue(mockProvider); + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: mockWithdrawalId, + timestamp: Date.now(), + amount: '50', + asset: 'USDC', + success: false, + status: 'pending', + source: 'hyperliquid', + }, + ]; + }); }); - it('tracks failed order edit via MetaMetrics', async () => { - mockProvider.editOrder.mockResolvedValue({ - success: false, - error: 'Order not found', + it('updates withdrawal status to completed with txHash', () => { + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('completed'); + expect(withdrawal.txHash).toBe(mockTxHash); + expect(withdrawal.success).toBe(true); + }); + + it('updates withdrawal status to failed', () => { + controller.updateWithdrawalStatus(mockWithdrawalId, 'failed'); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('failed'); + expect(withdrawal.success).toBe(false); + }); + + it('clears withdrawal progress when status completed', () => { + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 50, + lastUpdated: Date.now() - 1000, + activeWithdrawalId: mockWithdrawalId, + }; }); - const editParams = { - orderId: 'order123', - newOrder: { - coin: 'BTC', - isBuy: true, - orderType: 'limit' as const, - size: '1', - price: '50000', - }, - }; + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + }); - await controller.editOrder(editParams); + it('clears withdrawal progress when status failed', () => { + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 75, + lastUpdated: Date.now() - 1000, + activeWithdrawalId: mockWithdrawalId, + }; + }); + + controller.updateWithdrawalStatus(mockWithdrawalId, 'failed'); - // Check that MetaMetrics was called (the mock might be called with empty object) - expect(MetaMetrics.getInstance().trackEvent).toHaveBeenCalled(); + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + }); + + it('finds withdrawal by ID', () => { + controller.testUpdate((state) => { + state.withdrawalRequests.push({ + id: 'withdrawal-456', + timestamp: Date.now(), + amount: '75', + asset: 'USDC', + success: false, + status: 'pending', + source: 'hyperliquid', + }); + }); + + controller.updateWithdrawalStatus( + 'withdrawal-456', + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests[1].status).toBe('completed'); + expect(controller.state.withdrawalRequests[0].status).toBe('pending'); + }); + + it('does nothing when withdrawal ID not found', () => { + const initialRequests = [...controller.state.withdrawalRequests]; + + controller.updateWithdrawalStatus( + 'non-existent-id', + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests).toEqual(initialRequests); + }); + + it('updates state correctly for multiple withdrawals', () => { + controller.testUpdate((state) => { + state.withdrawalRequests.push({ + id: 'withdrawal-789', + timestamp: Date.now(), + amount: '100', + asset: 'USDC', + success: false, + status: 'pending', + source: 'hyperliquid', + }); + }); + + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests[0].status).toBe('completed'); + expect(controller.state.withdrawalRequests[1].status).toBe('pending'); + }); + + it('handles undefined txHash gracefully', () => { + controller.updateWithdrawalStatus(mockWithdrawalId, 'completed'); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('completed'); + expect(withdrawal.txHash).toBeUndefined(); + expect(withdrawal.success).toBe(true); }); }); - describe('getAvailableDexs', () => { + describe('markFirstOrderCompleted', () => { beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(controller, 'getActiveProvider').mockReturnValue(mockProvider); + markControllerAsInitialized(); }); - it('returns available HIP-3 DEXs from provider', async () => { - const mockDexs = ['dex1', 'dex2', 'dex3']; - mockProvider.getAvailableDexs = jest.fn().mockResolvedValue(mockDexs); + it('marks first order completed for mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); - const result = await controller.getAvailableDexs(); + controller.markFirstOrderCompleted(); - expect(result).toEqual(mockDexs); - expect(mockProvider.getAvailableDexs).toHaveBeenCalledWith(undefined); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); }); - it('passes filter parameters to provider', async () => { - const mockDexs = ['dex1']; - const filterParams = { validated: true }; - mockProvider.getAvailableDexs = jest.fn().mockResolvedValue(mockDexs); + it('marks first order completed for testnet', () => { + controller.testUpdate((state) => { + state.isTestnet = true; + }); - const result = await controller.getAvailableDexs(filterParams); + controller.markFirstOrderCompleted(); - expect(result).toEqual(mockDexs); - expect(mockProvider.getAvailableDexs).toHaveBeenCalledWith(filterParams); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(true); }); - it('throws error when provider does not support HIP-3', async () => { - // Cast to any to test undefined case - (mockProvider.getAvailableDexs as any) = undefined; + it('only updates status for current network', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + state.hasPlacedFirstOrder = { + mainnet: false, + testnet: false, + }; + }); - await expect(controller.getAvailableDexs()).rejects.toThrow( - 'Provider does not support HIP-3 DEXs', + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(false); + }); + + it('does not crash when called multiple times', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.markFirstOrderCompleted(); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + + controller.markFirstOrderCompleted(); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + }); + + it('logs completion without throwing', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + expect(() => controller.markFirstOrderCompleted()).not.toThrow(); + }); + }); + + describe('getWithdrawalRoutes error handling', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('logs error in getWithdrawalRoutes when provider throws', () => { + const mockError = new Error('Provider error'); + jest + .spyOn(MarketDataService, 'getWithdrawalRoutes') + .mockImplementation(() => { + throw mockError; + }); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual([]); + expect(Logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'PerpsController', + data: expect.objectContaining({ + method: 'getWithdrawalRoutes', + }), + }), + }), ); }); + + it('returns empty array from getWithdrawalRoutes on error', () => { + jest + .spyOn(MarketDataService, 'getWithdrawalRoutes') + .mockImplementation(() => { + throw new Error('Service failure'); + }); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual([]); + }); + + it('handles edge case with null provider gracefully', () => { + controller.testSetProviders(new Map()); + + expect(() => controller.getWithdrawalRoutes()).not.toThrow(); + expect(controller.getWithdrawalRoutes()).toEqual([]); + }); + }); + + describe('toggleTestnet', () => { + it('returns error when already reinitializing', async () => { + await controller.init(); + (controller as any).isReinitializing = true; + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.CLIENT_REINITIALIZING); + expect(result.isTestnet).toBe(false); + }); + + it('toggles to testnet network', async () => { + await controller.init(); + const initialTestnetState = controller.state.isTestnet; + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(true); + expect(result.isTestnet).toBe(!initialTestnetState); + expect(controller.state.isTestnet).toBe(!initialTestnetState); + }); + }); + + describe('market filter preferences', () => { + it('saves and retrieves filter preference', () => { + controller.saveMarketFilterPreferences('openInterest'); + + const result = controller.getMarketFilterPreferences(); + + expect(result).toBe('openInterest'); + }); + }); + + describe('watchlist management', () => { + it('adds and removes market from watchlist', async () => { + await controller.init(); + + controller.toggleWatchlistMarket('BTC'); + + expect(controller.isWatchlistMarket('BTC')).toBe(true); + expect(controller.getWatchlistMarkets()).toContain('BTC'); + + controller.toggleWatchlistMarket('BTC'); + + expect(controller.isWatchlistMarket('BTC')).toBe(false); + }); + }); + + describe('resetFirstTimeUserState', () => { + it('resets tutorial and order state for both networks', () => { + controller.markTutorialCompleted(); + controller.markFirstOrderCompleted(); + + controller.resetFirstTimeUserState(); + + expect(controller.state.isFirstTimeUser.testnet).toBe(true); + expect(controller.state.isFirstTimeUser.mainnet).toBe(true); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(false); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(false); + }); + }); + + describe('trade configuration', () => { + it('returns undefined for unsaved configuration', () => { + const result = controller.getTradeConfiguration('ETH'); + + expect(result).toBeUndefined(); + }); + + it('retrieves saved configuration', () => { + controller.saveTradeConfiguration('BTC', 10); + + const result = controller.getTradeConfiguration('BTC'); + + expect(result?.leverage).toBe(10); + }); }); }); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 8526a76083a..089c4e8b845 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -5,58 +5,41 @@ import { StateMetadata, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import { successfulFetch, toHex } from '@metamask/controller-utils'; import type { NetworkControllerGetStateAction } from '@metamask/network-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { TransactionControllerTransactionConfirmedEvent, TransactionControllerTransactionFailedEvent, TransactionControllerTransactionSubmittedEvent, - TransactionParams, TransactionType, } from '@metamask/transaction-controller'; -import { parseCaipAssetId, type Hex, hasProperty } from '@metamask/utils'; -import performance from 'react-native-performance'; -import { setMeasurement } from '@sentry/react-native'; -import type { Span } from '@sentry/core'; -import { v4 as uuidv4 } from 'uuid'; import Engine from '../../../../core/Engine'; -import { generateDepositId } from '../utils/idUtils'; import { USDC_SYMBOL } from '../constants/hyperLiquidConfig'; -import { isTPSLOrder } from '../constants/orderTypes'; import { LastTransactionResult, TransactionStatus, } from '../types/transactionTypes'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../util/Logger'; -import { getEvmAccountFromSelectedAccountGroup } from '../utils/accountUtils'; -import { generateTransferData } from '../../../../util/transactions'; -import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; -import { - trace, - endTrace, - TraceName, - TraceOperation, -} from '../../../../util/trace'; -import { MetaMetrics, MetaMetricsEvents } from '../../../../core/Analytics'; -import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; -import { - PerpsEventProperties, - PerpsEventValues, -} from '../constants/eventNames'; +import { MetaMetrics } from '../../../../core/Analytics'; import { ensureError } from '../utils/perpsErrorHandler'; import type { CandleData } from '../types/perps-types'; import { CandlePeriod } from '../constants/chartConfig'; -import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { - DATA_LAKE_API_CONFIG, PERPS_CONSTANTS, MARKET_SORTING_CONFIG, type SortOptionId, } from '../constants/perpsConfig'; import { PERPS_ERROR_CODES } from './perpsErrorCodes'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; +import { MarketDataService } from './services/MarketDataService'; +import { TradingService } from './services/TradingService'; +import { AccountService } from './services/AccountService'; +import { EligibilityService } from './services/EligibilityService'; +import { DataLakeService } from './services/DataLakeService'; +import { DepositService } from './services/DepositService'; +import { FeatureFlagConfigurationService } from './services/FeatureFlagConfigurationService'; +import type { ServiceContext } from './services/ServiceContext'; import { getStreamManagerInstance, type PerpsStreamManager, @@ -106,17 +89,11 @@ import type { GetHistoricalPortfolioParams, HistoricalPortfolioResult, } from './types'; -import { getEnvironment } from './utils'; import type { RemoteFeatureFlagControllerState, RemoteFeatureFlagControllerStateChangeEvent, RemoteFeatureFlagControllerGetStateAction, } from '@metamask/remote-feature-flag-controller'; -import { - type VersionGatedFeatureFlag, - validatedVersionGatedFeatureFlag, -} from '../../../../util/remoteFeatureFlag'; -import { parseCommaSeparatedString } from '../utils/stringParseUtils'; import { wait } from '../utils/wait'; // Re-export error codes from separate file to avoid circular dependencies @@ -132,15 +109,6 @@ export enum InitializationState { FAILED = 'failed', } -const ON_RAMP_GEO_BLOCKING_URLS = { - // Use UAT endpoint since DEV endpoint is less reliable. - DEV: 'https://on-ramp.uat-api.cx.metamask.io/geolocation', - PROD: 'https://on-ramp.api.cx.metamask.io/geolocation', -}; - -// Temporary to avoids estimation failures due to insufficient balance. -const DEPOSIT_GAS_LIMIT = toHex(100000); - /** * State shape for PerpsController */ @@ -172,9 +140,6 @@ export type PerpsControllerState = { }; }; - // Order management (trackingData never stored, only used for analytics) - pendingOrders: Omit[]; - // Simple deposit state (transient, for UI feedback) depositInProgress: boolean; // Internal transaction id for the deposit transaction @@ -285,7 +250,6 @@ export const getDefaultPerpsControllerState = (): PerpsControllerState => ({ accountState: null, positions: [], perpsBalances: {}, - pendingOrders: [], depositInProgress: false, lastDepositResult: null, withdrawInProgress: false, @@ -379,12 +343,6 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, - pendingOrders: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, depositInProgress: { includeInStateLogs: true, persist: false, @@ -660,18 +618,12 @@ export class PerpsController extends BaseController< PerpsControllerState, PerpsControllerMessenger > { - private providers: Map; - private isInitialized = false; + protected providers: Map; + protected isInitialized = false; private initializationPromise: Promise | null = null; private isReinitializing = false; - // Geo-location cache - private geoLocationCache: { location: string; timestamp: number } | null = - null; - private geoLocationFetchPromise: Promise | null = null; - private readonly GEO_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes - - private blockedRegionList: BlockedRegionList = { + protected blockedRegionList: BlockedRegionList = { list: [], source: 'fallback', }; @@ -742,23 +694,23 @@ export class PerpsController extends BaseController< this.providers = new Map(); } - private setBlockedRegionList(list: string[], source: 'remote' | 'fallback') { - // Never downgrade from remote to fallback - if (source === 'fallback' && this.blockedRegionList.source === 'remote') - return; - - if (Array.isArray(list)) { - this.blockedRegionList = { - list, - source, - }; - } - - this.refreshEligibility().catch((error) => { - Logger.error( - ensureError(error), - this.getErrorContext('setBlockedRegionList', { source }), - ); + protected setBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + FeatureFlagConfigurationService.setBlockedRegions({ + list, + source, + context: this.createServiceContext('setBlockedRegionList', { + getBlockedRegionList: () => this.blockedRegionList, + setBlockedRegionList: ( + newList: string[], + newSource: 'remote' | 'fallback', + ) => { + this.blockedRegionList = { list: newList, source: newSource }; + }, + refreshEligibility: () => this.refreshEligibility(), + }), }); } @@ -768,233 +720,52 @@ export class PerpsController extends BaseController< * Uses fallback configuration when remote feature flag is undefined. * Note: Initial eligibility is set in the constructor if fallback regions are provided. */ - private refreshEligibilityOnFeatureFlagChange( - remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState, - ): void { - const perpsGeoBlockedRegionsFeatureFlag = - // NOTE: Do not use perpsPerpTradingGeoBlockedCountries as it is deprecated. - remoteFeatureFlagControllerState.remoteFeatureFlags - ?.perpsPerpTradingGeoBlockedCountriesV2; - - const remoteBlockedRegions = ( - perpsGeoBlockedRegionsFeatureFlag as { blockedRegions?: string[] } - )?.blockedRegions; - - if (Array.isArray(remoteBlockedRegions)) { - this.setBlockedRegionList(remoteBlockedRegions, 'remote'); - } - - // Also check for HIP-3 config changes - this.refreshHip3ConfigOnFeatureFlagChange(remoteFeatureFlagControllerState); - } - - /** - * Refresh HIP-3 configuration when remote feature flags change. - * This method extracts HIP-3 settings from remote flags, validates them, - * and updates internal state if they differ from current values. - * When config changes, increments hip3ConfigVersion to trigger ConnectionManager reconnection. - * - * Follows the "sticky remote" pattern: once remote config is loaded, never downgrade to fallback. - * - * @param remoteFeatureFlagControllerState - State from RemoteFeatureFlagController - */ - private refreshHip3ConfigOnFeatureFlagChange( + protected refreshEligibilityOnFeatureFlagChange( remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState, ): void { - const remoteFlags = remoteFeatureFlagControllerState.remoteFeatureFlags; - - // Extract and validate remote HIP-3 equity enabled flag - const equityFlag = - remoteFlags?.perpsHip3Enabled as unknown as VersionGatedFeatureFlag; - const validatedEquity = validatedVersionGatedFeatureFlag(equityFlag); - - DevLogger.log('PerpsController: HIP-3 equity flag validation', { - equityFlag, - validatedEquity, - willUse: validatedEquity !== undefined ? 'remote' : 'fallback', - }); - - // Extract and validate remote HIP-3 allowlist markets (allowlist) - let validatedAllowlistMarkets: string[] | undefined; - if (hasProperty(remoteFlags, 'perpsHip3AllowlistMarkets')) { - const remoteMarkets = remoteFlags.perpsHip3AllowlistMarkets; - - DevLogger.log('PerpsController: HIP-3 allowlistMarkets validation', { - remoteMarkets, - type: typeof remoteMarkets, - isArray: Array.isArray(remoteMarkets), - }); - - // LaunchDarkly returns comma-separated strings for list values - if (typeof remoteMarkets === 'string') { - const parsed = parseCommaSeparatedString(remoteMarkets); - - if (parsed.length > 0) { - validatedAllowlistMarkets = parsed; - DevLogger.log( - 'PerpsController: HIP-3 allowlistMarkets validated from string', - { validatedAllowlistMarkets }, - ); - } else { - DevLogger.log( - 'PerpsController: HIP-3 allowlistMarkets string was empty after parsing', - { fallbackValue: this.hip3AllowlistMarkets }, - ); - } - } else if ( - Array.isArray(remoteMarkets) && - remoteMarkets.every( - (item) => typeof item === 'string' && item.length > 0, - ) - ) { - // Fallback: Validate array of non-empty strings (in case format changes) - validatedAllowlistMarkets = (remoteMarkets as string[]) - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - DevLogger.log( - 'PerpsController: HIP-3 allowlistMarkets validated from array', - { validatedAllowlistMarkets }, - ); - } else { - DevLogger.log( - 'PerpsController: HIP-3 allowlistMarkets validation FAILED - falling back to local config', - { - reason: Array.isArray(remoteMarkets) - ? 'Array contains non-string or empty values' - : 'Invalid type (expected string or array)', - fallbackValue: this.hip3AllowlistMarkets, + FeatureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState, + context: this.createServiceContext( + 'refreshEligibilityOnFeatureFlagChange', + { + getBlockedRegionList: () => this.blockedRegionList, + setBlockedRegionList: ( + list: string[], + source: 'remote' | 'fallback', + ) => { + this.blockedRegionList = { list, source }; }, - ); - } - } - - // Extract and validate remote HIP-3 blocklist markets (blocklist) - let validatedBlocklistMarkets: string[] | undefined; - if (hasProperty(remoteFlags, 'perpsHip3BlocklistMarkets')) { - const remoteBlocked = remoteFlags.perpsHip3BlocklistMarkets; - - DevLogger.log('PerpsController: HIP-3 blocklistMarkets validation', { - remoteBlocked, - type: typeof remoteBlocked, - isArray: Array.isArray(remoteBlocked), - }); - - // LaunchDarkly returns comma-separated strings for list values - if (typeof remoteBlocked === 'string') { - const parsed = parseCommaSeparatedString(remoteBlocked); - - if (parsed.length > 0) { - validatedBlocklistMarkets = parsed; - DevLogger.log( - 'PerpsController: HIP-3 blocklistMarkets validated from string', - { validatedBlocklistMarkets }, - ); - } else { - DevLogger.log( - 'PerpsController: HIP-3 blocklistMarkets string was empty after parsing', - { fallbackValue: this.hip3BlocklistMarkets }, - ); - } - } else if ( - Array.isArray(remoteBlocked) && - remoteBlocked.every( - (item) => typeof item === 'string' && item.length > 0, - ) - ) { - // Fallback: Validate array of non-empty strings (in case format changes) - validatedBlocklistMarkets = (remoteBlocked as string[]) - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - DevLogger.log( - 'PerpsController: HIP-3 blocklistMarkets validated from array', - { validatedBlocklistMarkets }, - ); - } else { - DevLogger.log( - 'PerpsController: HIP-3 blocklistMarkets validation FAILED - falling back to local config', - { - reason: Array.isArray(remoteBlocked) - ? 'Array contains non-string or empty values' - : 'Invalid type (expected string or array)', - fallbackValue: this.hip3BlocklistMarkets, + refreshEligibility: () => this.refreshEligibility(), + getHip3Config: () => ({ + enabled: this.hip3Enabled, + allowlistMarkets: this.hip3AllowlistMarkets, + blocklistMarkets: this.hip3BlocklistMarkets, + source: this.hip3ConfigSource, + }), + setHip3Config: (config) => { + if (config.enabled !== undefined) { + this.hip3Enabled = config.enabled; + } + if (config.allowlistMarkets !== undefined) { + this.hip3AllowlistMarkets = [...config.allowlistMarkets]; + } + if (config.blocklistMarkets !== undefined) { + this.hip3BlocklistMarkets = [...config.blocklistMarkets]; + } + if (config.source !== undefined) { + this.hip3ConfigSource = config.source; + } + }, + incrementHip3ConfigVersion: () => { + const newVersion = (this.state.hip3ConfigVersion || 0) + 1; + this.update((state) => { + state.hip3ConfigVersion = newVersion; + }); + return newVersion; }, - ); - } - } - - // Detect changes (only if we have valid remote values) - const equityChanged = - validatedEquity !== undefined && validatedEquity !== this.hip3Enabled; - const allowlistMarketsChanged = - validatedAllowlistMarkets !== undefined && - JSON.stringify( - [...validatedAllowlistMarkets].sort((a, b) => a.localeCompare(b)), - ) !== - JSON.stringify( - [...this.hip3AllowlistMarkets].sort((a, b) => a.localeCompare(b)), - ); - const blocklistMarketsChanged = - validatedBlocklistMarkets !== undefined && - JSON.stringify( - [...validatedBlocklistMarkets].sort((a, b) => a.localeCompare(b)), - ) !== - JSON.stringify( - [...this.hip3BlocklistMarkets].sort((a, b) => a.localeCompare(b)), - ); - - if (equityChanged || allowlistMarketsChanged || blocklistMarketsChanged) { - DevLogger.log( - 'PerpsController: HIP-3 config changed via remote feature flags', - { - equityChanged, - allowlistMarketsChanged, - blocklistMarketsChanged, - oldEquity: this.hip3Enabled, - newEquity: validatedEquity, - oldAllowlistMarkets: this.hip3AllowlistMarkets, - newAllowlistMarkets: validatedAllowlistMarkets, - oldBlocklistMarkets: this.hip3BlocklistMarkets, - newBlocklistMarkets: validatedBlocklistMarkets, - source: 'remote', - }, - ); - - // Update internal state (sticky remote - never downgrade) - if (validatedEquity !== undefined) { - this.hip3Enabled = validatedEquity; - } - if (validatedAllowlistMarkets !== undefined) { - this.hip3AllowlistMarkets = [...validatedAllowlistMarkets]; - } - if (validatedBlocklistMarkets !== undefined) { - this.hip3BlocklistMarkets = [...validatedBlocklistMarkets]; - } - this.hip3ConfigSource = 'remote'; - - // Increment version to trigger ConnectionManager reconnection and cache clearing - const newVersion = (this.state.hip3ConfigVersion || 0) + 1; - this.update((state) => { - state.hip3ConfigVersion = newVersion; - }); - - DevLogger.log( - 'PerpsController: Incremented hip3ConfigVersion to trigger reconnection', - { - newVersion, - newHip3Enabled: this.hip3Enabled, - newHip3AllowlistMarkets: this.hip3AllowlistMarkets, - newHip3BlocklistMarkets: this.hip3BlocklistMarkets, }, - ); - - // Note: ConnectionManager will handle: - // 1. Detecting hip3ConfigVersion change via Redux monitoring - // 2. Clearing all StreamManager caches - // 3. Calling reconnectWithNewContext() -> initializeProviders() - // 4. Provider reinitialization will read the new HIP-3 config below - } + ), + }); } /** @@ -1072,128 +843,6 @@ export class PerpsController extends BaseController< } } - /** - * Calculate user fee discount from RewardsController - * Used to apply MetaMask reward discounts to trading fees - * @param parentSpan - Optional parent span to attach measurement to (for order traces) - * @returns Fee discount in basis points (e.g., 550 for 5.5% off) or undefined if no discount - * @private - */ - private async calculateUserFeeDiscount( - parentSpan?: Span, - ): Promise { - // Only create standalone trace if no parent span provided - const traceId = parentSpan ? undefined : uuidv4(); - let traceData: Record | undefined; - - try { - // Start standalone trace only if no parent span - const traceSpan = - parentSpan || - (traceId - ? trace({ - name: TraceName.PerpsRewardsAPICall, - id: traceId, - op: TraceOperation.PerpsOperation, - }) - : undefined); - - const { RewardsController, NetworkController } = Engine.context; - const evmAccount = getEvmAccountFromSelectedAccountGroup(); - - if (!evmAccount) { - DevLogger.log('PerpsController: No EVM account found for fee discount'); - return undefined; - } - - // Get the chain ID using proper NetworkController method - const networkState = this.messenger.call('NetworkController:getState'); - const selectedNetworkClientId = networkState.selectedNetworkClientId; - const networkClient = NetworkController.getNetworkClientById( - selectedNetworkClientId, - ); - const chainId = networkClient?.configuration?.chainId; - - if (!chainId) { - Logger.error( - new Error('Chain ID not found for fee discount calculation'), - this.getErrorContext('calculateUserFeeDiscount', { - selectedNetworkClientId, - networkClientExists: !!networkClient, - }), - ); - return undefined; - } - - const caipAccountId = formatAccountToCaipAccountId( - evmAccount.address, - chainId, - ); - - if (!caipAccountId) { - Logger.error( - new Error('Failed to format CAIP account ID for fee discount'), - this.getErrorContext('calculateUserFeeDiscount', { - address: evmAccount.address, - chainId, - selectedNetworkClientId, - }), - ); - return undefined; - } - - const orderExecutionFeeDiscountStartTime = performance.now(); - const discountBips = - await RewardsController.getPerpsDiscountForAccount(caipAccountId); - const orderExecutionFeeDiscountDuration = - performance.now() - orderExecutionFeeDiscountStartTime; - - // Attach measurement once to the appropriate span - setMeasurement( - PerpsMeasurementName.PERPS_REWARDS_ORDER_EXECUTION_FEE_DISCOUNT_API_CALL, - orderExecutionFeeDiscountDuration, - 'millisecond', - traceSpan, - ); - - DevLogger.log('PerpsController: Fee discount calculated', { - address: evmAccount.address, - caipAccountId, - discountBips, - discountPercentage: discountBips / 100, - duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, - }); - - traceData = { - success: true, - discountBips, - }; - - return discountBips; - } catch (error) { - Logger.error( - ensureError(error), - this.getErrorContext('calculateUserFeeDiscount'), - ); - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - - return undefined; - } finally { - // Only end trace if we created one (no parent span) - if (!parentSpan && traceId) { - endTrace({ - name: TraceName.PerpsRewardsAPICall, - id: traceId, - data: traceData, - }); - } - } - } - /** * Initialize the PerpsController providers * Must be called before using any other methods @@ -1370,6 +1019,36 @@ export class PerpsController extends BaseController< }; } + /** + * Create a ServiceContext for dependency injection into services + * Provides all orchestration dependencies (tracing, analytics, state management) + * + * @param method - Method name for error context + * @param additionalContext - Optional additional context (e.g., rewardsController, streamManager) + * @returns ServiceContext with all required dependencies + */ + private createServiceContext( + method: string, + additionalContext?: Partial, + ): ServiceContext { + return { + tracingContext: { + provider: this.state.activeProvider, + isTestnet: this.state.isTestnet, + }, + analytics: MetaMetrics.getInstance(), + errorContext: { + controller: 'PerpsController', + method, + }, + stateManager: { + update: (updater) => this.update(updater), + getState: () => this.state, + }, + ...additionalContext, + }; + } + /** * Get the currently active provider * @returns The active provider @@ -1416,1208 +1095,162 @@ export class PerpsController extends BaseController< /** * Place a new order + * Thin delegation to TradingService */ async placeOrder(params: OrderParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let traceData: - | { success: boolean; error?: string; orderId?: string } - | undefined; - - try { - // Start trace for the entire operation - const traceSpan = trace({ - name: TraceName.PerpsPlaceOrder, - id: traceId, - op: TraceOperation.PerpsOrderSubmission, - tags: { - provider: this.state.activeProvider, - orderType: params.orderType, - market: params.coin, - leverage: params.leverage || 1, - isTestnet: this.state.isTestnet, - }, - data: { - isBuy: params.isBuy, - orderPrice: params.price || '', - }, - }); - const provider = this.getActiveProvider(); + const provider = this.getActiveProvider(); + const { RewardsController, NetworkController } = Engine.context; + + return TradingService.placeOrder({ + provider, + params, + context: this.createServiceContext('placeOrder', { + rewardsController: RewardsController, + networkController: NetworkController, + messenger: this.messenger, + saveTradeConfiguration: (coin: string, leverage: number) => + this.saveTradeConfiguration(coin, leverage), + }), + reportOrderToDataLake: (dataLakeParams) => + this.reportOrderToDataLake(dataLakeParams), + }); + } - // Calculate fee discount at execution time (fresh, secure) - const feeDiscountBips = await this.calculateUserFeeDiscount(traceSpan); + /** + * Edit an existing order + * Thin delegation to TradingService + */ + async editOrder(params: EditOrderParams): Promise { + const provider = this.getActiveProvider(); + const { RewardsController, NetworkController } = Engine.context; + + return TradingService.editOrder({ + provider, + params, + context: this.createServiceContext('editOrder', { + rewardsController: RewardsController, + networkController: NetworkController, + messenger: this.messenger, + }), + }); + } - DevLogger.log('PerpsController: Fee discount calculated', { - feeDiscountBips, - hasDiscount: feeDiscountBips !== undefined, - }); + /** + * Cancel an existing order + */ + async cancelOrder(params: CancelOrderParams): Promise { + const provider = this.getActiveProvider(); - // Set discount context in provider for this order - if (feeDiscountBips !== undefined && provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(feeDiscountBips); - DevLogger.log('PerpsController: Fee discount set in provider', { - feeDiscountBips, - }); - } + return TradingService.cancelOrder({ + provider, + params, + context: this.createServiceContext('cancelOrder'), + }); + } - // Optimistic update - exclude trackingData to avoid persisting analytics data - const { trackingData, ...orderWithoutTracking } = params; - this.update((state) => { - state.pendingOrders.push(orderWithoutTracking); - }); + /** + * Cancel multiple orders in parallel + * Batch version of cancelOrder() that cancels multiple orders simultaneously + */ + async cancelOrders(params: CancelOrdersParams): Promise { + const provider = this.getActiveProvider(); - DevLogger.log('PerpsController: Submitting order to provider', { - coin: params.coin, - orderType: params.orderType, - isBuy: params.isBuy, - size: params.size, - leverage: params.leverage, - hasTP: !!params.takeProfitPrice, - hasSL: !!params.stopLossPrice, - }); + return TradingService.cancelOrders({ + provider, + params, + context: this.createServiceContext('cancelOrders', { + getOpenOrders: () => this.getOpenOrders(), + }), + withStreamPause: (operation: () => Promise, channels: string[]) => + this.withStreamPause( + operation, + channels as (keyof PerpsStreamManager)[], + ), + }); + } - let result: OrderResult; - try { - result = await provider.placeOrder(params); + /** + * Close a position (partial or full) + * Thin delegation to TradingService + */ + async closePosition(params: ClosePositionParams): Promise { + const provider = this.getActiveProvider(); + const { RewardsController, NetworkController } = Engine.context; + + return TradingService.closePosition({ + provider, + params, + context: this.createServiceContext('closePosition', { + rewardsController: RewardsController, + networkController: NetworkController, + messenger: this.messenger, + getPositions: () => this.getPositions(), + }), + reportOrderToDataLake: (dataLakeParams) => + this.reportOrderToDataLake(dataLakeParams), + }); + } - DevLogger.log('PerpsController: Provider response received', { - success: result.success, - orderId: result.orderId, - error: result.error, - }); - } finally { - // Always clear discount context, even on exception - if (provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(undefined); - DevLogger.log('PerpsController: Fee discount cleared from provider'); - } - } + /** + * Close multiple positions in parallel + * Batch version of closePosition() that closes multiple positions simultaneously + */ + async closePositions( + params: ClosePositionsParams, + ): Promise { + const provider = this.getActiveProvider(); + const { RewardsController, NetworkController } = Engine.context; + + return TradingService.closePositions({ + provider, + params, + context: this.createServiceContext('closePositions', { + rewardsController: RewardsController, + networkController: NetworkController, + messenger: this.messenger, + getPositions: () => this.getPositions(), + }), + }); + } - // Update state only on success - if (result.success) { - this.update((state) => { - state.pendingOrders = state.pendingOrders.filter( - (o) => o !== orderWithoutTracking, - ); - state.lastUpdateTimestamp = Date.now(); - }); + /** + * Update TP/SL for an existing position + */ + async updatePositionTPSL( + params: UpdatePositionTPSLParams, + ): Promise { + const provider = this.getActiveProvider(); + const { RewardsController, NetworkController } = Engine.context; + + return TradingService.updatePositionTPSL({ + provider, + params, + context: this.createServiceContext('updatePositionTPSL', { + rewardsController: RewardsController, + networkController: NetworkController, + messenger: this.messenger, + }), + }); + } - // Save executed trade configuration for this market - if (params.leverage) { - this.saveTradeConfiguration(params.coin, params.leverage); - } + /** + * Simplified deposit method that prepares transaction for confirmation screen + * No complex state tracking - just sets a loading flag + */ + async depositWithConfirmation(amount?: string) { + const { NetworkController, TransactionController } = Engine.context; - // Track trade transaction executed - const completionDuration = performance.now() - startTime; - - const eventBuilder = MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - ).addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.DIRECTION]: params.isBuy - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - [PerpsEventProperties.ORDER_TYPE]: params.orderType, - [PerpsEventProperties.LEVERAGE]: params.leverage || 1, - [PerpsEventProperties.ORDER_SIZE]: result.filledSize || params.size, - [PerpsEventProperties.ASSET_PRICE]: - result.averagePrice || params.trackingData?.marketPrice, - [PerpsEventProperties.MARGIN_USED]: params.trackingData?.marginUsed, - [PerpsEventProperties.METAMASK_FEE]: params.trackingData?.metamaskFee, - [PerpsEventProperties.METAMASK_FEE_RATE]: - params.trackingData?.metamaskFeeRate, - [PerpsEventProperties.DISCOUNT_PERCENTAGE]: - params.trackingData?.feeDiscountPercentage, - [PerpsEventProperties.ESTIMATED_REWARDS]: - params.trackingData?.estimatedPoints, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - // Add TP/SL if set (for new orders) - ...(params.takeProfitPrice && { - [PerpsEventProperties.TAKE_PROFIT_PRICE]: parseFloat( - params.takeProfitPrice, - ), - }), - ...(params.stopLossPrice && { - [PerpsEventProperties.STOP_LOSS_PRICE]: parseFloat( - params.stopLossPrice, - ), - }), - }); + try { + // Clear any stale results when starting a new deposit flow + // Don't set depositInProgress yet - wait until user confirms - MetaMetrics.getInstance().trackEvent(eventBuilder.build()); - - // Report to data lake (fire-and-forget with retry) - this.reportOrderToDataLake({ - action: 'open', - coin: params.coin, - sl_price: params.stopLossPrice - ? parseFloat(params.stopLossPrice) - : undefined, - tp_price: params.takeProfitPrice - ? parseFloat(params.takeProfitPrice) - : undefined, - }).catch((error) => { - Logger.error( - ensureError(error), - this.getErrorContext('placeOrder', { - operation: 'reportOrderToDataLake', - coin: params.coin, - }), - ); - }); + // Prepare deposit transaction using DepositService + const provider = this.getActiveProvider(); + const { transaction, assetChainId, currentDepositId } = + await DepositService.prepareTransaction({ provider }); - traceData = { success: true, orderId: result.orderId || '' }; - } else { - // Track trade transaction failed - const completionDuration = performance.now() - startTime; - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.DIRECTION]: params.isBuy - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - [PerpsEventProperties.ORDER_TYPE]: params.orderType, - [PerpsEventProperties.LEVERAGE]: params.leverage || 1, - [PerpsEventProperties.ORDER_SIZE]: params.size, - [PerpsEventProperties.MARGIN_USED]: - params.trackingData?.marginUsed, - [PerpsEventProperties.LIMIT_PRICE]: - params.orderType === 'limit' ? params.price : null, - [PerpsEventProperties.FEES]: params.trackingData?.totalFee, - [PerpsEventProperties.ASSET_PRICE]: - params.trackingData?.marketPrice, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - result.error || 'Unknown error', - }) - .build(), - ); - - // Remove from pending orders even on failure since the attempt is complete - this.update((state) => { - state.pendingOrders = state.pendingOrders.filter( - (o) => o !== orderWithoutTracking, - ); - }); - - traceData = { success: false, error: result.error || 'Unknown error' }; - } - - return result; - } catch (error) { - // Track trade transaction failed (catch block) - const completionDuration = performance.now() - startTime; - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.DIRECTION]: params.isBuy - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - [PerpsEventProperties.ORDER_TYPE]: params.orderType, - [PerpsEventProperties.LEVERAGE]: params.leverage || 1, - [PerpsEventProperties.ORDER_SIZE]: params.size, - [PerpsEventProperties.MARGIN_USED]: params.trackingData?.marginUsed, - [PerpsEventProperties.LIMIT_PRICE]: - params.orderType === 'limit' ? params.price : null, - [PerpsEventProperties.FEES]: params.trackingData?.totalFee, - [PerpsEventProperties.ASSET_PRICE]: - params.trackingData?.marketPrice, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - error instanceof Error ? error.message : 'Unknown error', - }) - .build(), - ); - - // Clear discount context in case of error - try { - const provider = this.getActiveProvider(); - if (provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(undefined); - } - } catch (cleanupError) { - Logger.error( - ensureError(cleanupError), - this.getErrorContext('placeOrder', { - operation: 'clearFeeDiscount', - }), - ); - } - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - // Always end trace on exit (success or failure) - endTrace({ - name: TraceName.PerpsPlaceOrder, - id: traceId, - data: traceData, - }); - } - } - - /** - * Edit an existing order - */ - async editOrder(params: EditOrderParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let traceData: - | { success: boolean; error?: string; orderId?: string } - | undefined; - - try { - trace({ - name: TraceName.PerpsEditOrder, - id: traceId, - op: TraceOperation.PerpsOrderSubmission, - tags: { - provider: this.state.activeProvider, - orderType: params.newOrder.orderType, - market: params.newOrder.coin, - leverage: params.newOrder.leverage || 1, - isTestnet: this.state.isTestnet, - }, - data: { - isBuy: params.newOrder.isBuy, - orderPrice: params.newOrder.price || '', - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.editOrder(params); - const completionDuration = performance.now() - startTime; - - if (result.success) { - this.update((state) => { - state.lastUpdateTimestamp = Date.now(); - }); - - // Track order edit executed - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, - [PerpsEventProperties.ASSET]: params.newOrder.coin, - [PerpsEventProperties.DIRECTION]: params.newOrder.isBuy - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - [PerpsEventProperties.ORDER_TYPE]: params.newOrder.orderType, - [PerpsEventProperties.LEVERAGE]: params.newOrder.leverage || 1, - [PerpsEventProperties.ORDER_SIZE]: params.newOrder.size, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - ...(params.newOrder.price && { - [PerpsEventProperties.LIMIT_PRICE]: parseFloat( - params.newOrder.price, - ), - }), - }) - .build(), - ); - - traceData = { success: true, orderId: result.orderId || '' }; - } else { - // Track order edit failed - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.newOrder.coin, - [PerpsEventProperties.DIRECTION]: params.newOrder.isBuy - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - [PerpsEventProperties.ORDER_TYPE]: params.newOrder.orderType, - [PerpsEventProperties.LEVERAGE]: params.newOrder.leverage || 1, - [PerpsEventProperties.ORDER_SIZE]: params.newOrder.size, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - result.error || 'Unknown error', - }) - .build(), - ); - - traceData = { success: false, error: result.error || 'Unknown error' }; - } - - return result; - } catch (error) { - const completionDuration = performance.now() - startTime; - - // Track order edit exception - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.newOrder.coin, - [PerpsEventProperties.DIRECTION]: params.newOrder.isBuy - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - [PerpsEventProperties.ORDER_TYPE]: params.newOrder.orderType, - [PerpsEventProperties.LEVERAGE]: params.newOrder.leverage || 1, - [PerpsEventProperties.ORDER_SIZE]: params.newOrder.size, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - error instanceof Error ? error.message : 'Unknown error', - }) - .build(), - ); - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - endTrace({ - name: TraceName.PerpsEditOrder, - id: traceId, - data: traceData, - }); - } - } - - /** - * Cancel an existing order - */ - async cancelOrder(params: CancelOrderParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let traceData: - | { success: boolean; error?: string; orderId?: string } - | undefined; - - try { - trace({ - name: TraceName.PerpsCancelOrder, - id: traceId, - op: TraceOperation.PerpsOrderSubmission, - tags: { - provider: this.state.activeProvider, - market: params.coin, - isTestnet: this.state.isTestnet, - }, - data: { - orderId: params.orderId, - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.cancelOrder(params); - const completionDuration = performance.now() - startTime; - - if (result.success) { - this.update((state) => { - state.lastUpdateTimestamp = Date.now(); - }); - - // Track order cancel executed - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - }) - .build(), - ); - - traceData = { success: true, orderId: params.orderId }; - } else { - // Track order cancel failed - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - result.error || 'Unknown error', - }) - .build(), - ); - - traceData = { success: false, error: result.error || 'Unknown error' }; - } - - return result; - } catch (error) { - const completionDuration = performance.now() - startTime; - - // Track order cancel exception - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - error instanceof Error ? error.message : 'Unknown error', - }) - .build(), - ); - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - endTrace({ - name: TraceName.PerpsCancelOrder, - id: traceId, - data: traceData, - }); - } - } - - /** - * Cancel multiple orders in parallel - * Batch version of cancelOrder() that cancels multiple orders simultaneously - */ - async cancelOrders(params: CancelOrdersParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let operationResult: CancelOrdersResult | null = null; - let operationError: Error | null = null; - - try { - trace({ - name: TraceName.PerpsCancelOrder, - id: traceId, - op: TraceOperation.PerpsOrderSubmission, - tags: { - provider: this.state.activeProvider, - isBatch: 'true', - isTestnet: this.state.isTestnet, - }, - data: { - cancelAll: params.cancelAll ? 'true' : 'false', - coinCount: params.coins?.length || 0, - orderIdCount: params.orderIds?.length || 0, - }, - }); - - // Pause orders stream to prevent WebSocket updates during cancellation - operationResult = await this.withStreamPause(async () => { - // Get all open orders (using getOpenOrders to avoid duplicates from historicalOrders) - const orders = await this.getOpenOrders(); - - // Filter orders based on params - let ordersToCancel = orders; - if (params.cancelAll || (!params.coins && !params.orderIds)) { - // Cancel all orders (excluding TP/SL orders for positions) - ordersToCancel = orders.filter( - (o) => !isTPSLOrder(o.detailedOrderType), - ); - } else if (params.orderIds && params.orderIds.length > 0) { - // Cancel specific order IDs - ordersToCancel = orders.filter((o) => - params.orderIds?.includes(o.orderId), - ); - } else if (params.coins && params.coins.length > 0) { - // Cancel orders for specific coins - ordersToCancel = orders.filter((o) => - params.coins?.includes(o.symbol), - ); - } - - if (ordersToCancel.length === 0) { - return { - success: false, - successCount: 0, - failureCount: 0, - results: [], - }; - } - - const provider = this.getActiveProvider(); - - // Use batch cancel if provider supports it - if (provider.cancelOrders) { - return await provider.cancelOrders( - ordersToCancel.map((order) => ({ - coin: order.symbol, - orderId: order.orderId, - })), - ); - } - - // Fallback: Cancel orders in parallel (for providers without batch support) - const results = await Promise.allSettled( - ordersToCancel.map((order) => - this.cancelOrder({ coin: order.symbol, orderId: order.orderId }), - ), - ); - - // Aggregate results - const successCount = results.filter( - (r) => r.status === 'fulfilled' && r.value.success, - ).length; - const failureCount = results.length - successCount; - - return { - success: successCount > 0, - successCount, - failureCount, - results: results.map((result, index) => { - let error: string | undefined; - if (result.status === 'rejected') { - error = - result.reason instanceof Error - ? result.reason.message - : 'Unknown error'; - } else if (result.status === 'fulfilled' && !result.value.success) { - error = result.value.error; - } - - return { - orderId: ordersToCancel[index].orderId, - coin: ordersToCancel[index].symbol, - success: !!( - result.status === 'fulfilled' && result.value.success - ), - error, - }; - }), - }; - }, ['orders']); // Disconnect orders stream during operation - - return operationResult; - } catch (error) { - operationError = - error instanceof Error ? error : new Error(String(error)); - throw error; - } finally { - const completionDuration = performance.now() - startTime; - - // Track batch cancel event (success or failure) - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: - operationResult?.success && operationResult.successCount > 0 - ? PerpsEventValues.STATUS.EXECUTED - : PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - ...(operationError && { - [PerpsEventProperties.ERROR_MESSAGE]: operationError.message, - }), - // Note: Custom properties for batch tracking (totalCount, successCount, failureCount) - // can be added to PerpsEventProperties if needed for analytics - }) - .build(), - ); - - endTrace({ - name: TraceName.PerpsCancelOrder, - id: traceId, - }); - } - } - - /** - * Close a position (partial or full) - */ - async closePosition(params: ClosePositionParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - // Get position data for event tracking - let position: Position | undefined; - let traceData: - | { success: boolean; error?: string; filledSize?: string } - | undefined; - - try { - const traceSpan = trace({ - name: TraceName.PerpsClosePosition, - id: traceId, - op: TraceOperation.PerpsPositionManagement, - tags: { - provider: this.state.activeProvider, - coin: params.coin, - closeSize: params.size || 'full', - isTestnet: this.state.isTestnet, - }, - }); - - // Measure position loading time - const positionLoadStart = performance.now(); - try { - const positions = await this.getPositions(); - position = positions.find((p) => p.coin === params.coin); - setMeasurement( - PerpsMeasurementName.PERPS_GET_POSITIONS_OPERATION, - performance.now() - positionLoadStart, - 'millisecond', - traceSpan, - ); - } catch (err) { - DevLogger.log( - 'PerpsController: Could not get position data for tracking', - err, - ); - } - - const provider = this.getActiveProvider(); - - // Calculate fee discount at execution time (same as placeOrder) - const feeDiscountBips = await this.calculateUserFeeDiscount(traceSpan); - - // Set discount context in provider for this close operation - if (feeDiscountBips !== undefined && provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(feeDiscountBips); - } - - let result: OrderResult; - try { - result = await provider.closePosition(params); - } finally { - // Always clear discount context, even on exception - if (provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(undefined); - } - } - - const completionDuration = performance.now() - startTime; - - if (result.success && position) { - this.update((state) => { - state.lastUpdateTimestamp = Date.now(); - }); - - // Report to data lake (fire-and-forget with retry) - this.reportOrderToDataLake({ - action: 'close', - coin: params.coin, - }).catch((error) => { - Logger.error( - ensureError(error), - this.getErrorContext('closePosition', { - operation: 'reportOrderToDataLake', - coin: params.coin, - }), - ); - }); - - // Determine direction from position size - const direction = - parseFloat(position.size) > 0 - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT; - - // Check if partially filled - const filledSize = result.filledSize - ? parseFloat(result.filledSize) - : 0; - const requestedSize = params.size - ? parseFloat(params.size) - : Math.abs(parseFloat(position.size)); - const isPartiallyFilled = filledSize > 0 && filledSize < requestedSize; - - if (isPartiallyFilled) { - // Track partially filled event - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: - PerpsEventValues.STATUS.PARTIALLY_FILLED, - [PerpsEventProperties.ASSET]: position.coin, - [PerpsEventProperties.DIRECTION]: direction, - [PerpsEventProperties.OPEN_POSITION_SIZE]: Math.abs( - parseFloat(position.size), - ), - [PerpsEventProperties.ORDER_SIZE]: requestedSize, - [PerpsEventProperties.ORDER_TYPE]: - params.orderType || PerpsEventValues.ORDER_TYPE.MARKET, - [PerpsEventProperties.AMOUNT_FILLED]: filledSize, - [PerpsEventProperties.REMAINING_AMOUNT]: - requestedSize - filledSize, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - }) - .build(), - ); - } - - // Track position close executed event - const orderType = - params.orderType || PerpsEventValues.ORDER_TYPE.MARKET; - const closePercentage = params.size - ? (parseFloat(params.size) / Math.abs(parseFloat(position.size))) * - 100 - : 100; - const closeType = - closePercentage === 100 - ? PerpsEventValues.CLOSE_TYPE.FULL - : PerpsEventValues.CLOSE_TYPE.PARTIAL; - - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, - [PerpsEventProperties.ASSET]: position.coin, - [PerpsEventProperties.DIRECTION]: direction, - [PerpsEventProperties.ORDER_TYPE]: orderType, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.PERCENTAGE_CLOSED]: closePercentage, - [PerpsEventProperties.CLOSE_TYPE]: closeType, - // Add missing properties per specification - [PerpsEventProperties.OPEN_POSITION_SIZE]: Math.abs( - parseFloat(position.size), - ), - [PerpsEventProperties.ORDER_SIZE]: params.size - ? parseFloat(params.size) - : Math.abs(parseFloat(position.size)), - [PerpsEventProperties.PNL_DOLLAR]: position.unrealizedPnl - ? parseFloat(position.unrealizedPnl) - : null, - [PerpsEventProperties.PNL_PERCENT]: position.returnOnEquity - ? parseFloat(position.returnOnEquity) * 100 - : null, - [PerpsEventProperties.FEE]: params.trackingData?.totalFee || null, - [PerpsEventProperties.METAMASK_FEE]: - params.trackingData?.metamaskFee || null, - [PerpsEventProperties.METAMASK_FEE_RATE]: - params.trackingData?.metamaskFeeRate || null, - [PerpsEventProperties.DISCOUNT_PERCENTAGE]: - params.trackingData?.feeDiscountPercentage || null, - [PerpsEventProperties.ESTIMATED_REWARDS]: - params.trackingData?.estimatedPoints || null, - [PerpsEventProperties.ASSET_PRICE]: - params.trackingData?.marketPrice || result.averagePrice || null, - [PerpsEventProperties.LIMIT_PRICE]: - params.orderType === 'limit' ? params.price : null, - [PerpsEventProperties.RECEIVED_AMOUNT]: - params.trackingData?.receivedAmount || null, - }) - .build(), - ); - - traceData = { success: true, filledSize: result.filledSize || '' }; - } else if (!result.success && position) { - // Track position close failed event - const direction = - parseFloat(position.size) > 0 - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT; - - traceData = { success: false, error: result.error || 'Unknown error' }; - - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: position.coin, - [PerpsEventProperties.DIRECTION]: direction, - [PerpsEventProperties.ORDER_SIZE]: - params.size || Math.abs(parseFloat(position.size)), - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - result.error || 'Unknown error', - // Add missing properties per specification - [PerpsEventProperties.OPEN_POSITION_SIZE]: Math.abs( - parseFloat(position.size), - ), - [PerpsEventProperties.ORDER_TYPE]: - params.orderType || PerpsEventValues.ORDER_TYPE.MARKET, - [PerpsEventProperties.PERCENTAGE_CLOSED]: params.size - ? (parseFloat(params.size) / - Math.abs(parseFloat(position.size))) * - 100 - : 100, - [PerpsEventProperties.PNL_DOLLAR]: position.unrealizedPnl - ? parseFloat(position.unrealizedPnl) - : null, - [PerpsEventProperties.PNL_PERCENT]: position.returnOnEquity - ? parseFloat(position.returnOnEquity) * 100 - : null, - [PerpsEventProperties.FEE]: params.trackingData?.totalFee || null, - [PerpsEventProperties.METAMASK_FEE]: - params.trackingData?.metamaskFee || null, - [PerpsEventProperties.METAMASK_FEE_RATE]: - params.trackingData?.metamaskFeeRate || null, - [PerpsEventProperties.DISCOUNT_PERCENTAGE]: - params.trackingData?.feeDiscountPercentage || null, - [PerpsEventProperties.ESTIMATED_REWARDS]: - params.trackingData?.estimatedPoints || null, - [PerpsEventProperties.ASSET_PRICE]: - params.trackingData?.marketPrice || result.averagePrice || null, - [PerpsEventProperties.LIMIT_PRICE]: - params.orderType === 'limit' ? params.price : null, - [PerpsEventProperties.RECEIVED_AMOUNT]: - params.trackingData?.receivedAmount || null, - }) - .build(), - ); - } - - return result; - } catch (error) { - const completionDuration = performance.now() - startTime; - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - - if (position) { - // Track position close failed event for exceptions - const direction = - parseFloat(position.size) > 0 - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT; - - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: position.coin, - [PerpsEventProperties.DIRECTION]: direction, - [PerpsEventProperties.ORDER_SIZE]: - params.size || Math.abs(parseFloat(position.size)), - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - error instanceof Error ? error.message : 'Unknown error', - // Add missing properties per specification - [PerpsEventProperties.OPEN_POSITION_SIZE]: Math.abs( - parseFloat(position.size), - ), - [PerpsEventProperties.ORDER_TYPE]: - params.orderType || PerpsEventValues.ORDER_TYPE.MARKET, - [PerpsEventProperties.PERCENTAGE_CLOSED]: params.size - ? (parseFloat(params.size) / - Math.abs(parseFloat(position.size))) * - 100 - : 100, - [PerpsEventProperties.PNL_DOLLAR]: position.unrealizedPnl - ? parseFloat(position.unrealizedPnl) - : null, - [PerpsEventProperties.PNL_PERCENT]: position.returnOnEquity - ? parseFloat(position.returnOnEquity) * 100 - : null, - [PerpsEventProperties.FEE]: params.trackingData?.totalFee || null, - [PerpsEventProperties.ASSET_PRICE]: - params.trackingData?.marketPrice || null, - [PerpsEventProperties.LIMIT_PRICE]: - params.orderType === 'limit' ? params.price : null, - [PerpsEventProperties.RECEIVED_AMOUNT]: - params.trackingData?.receivedAmount || null, - }) - .build(), - ); - } - throw error; - } finally { - // Always end trace on exit (success or failure) - endTrace({ - name: TraceName.PerpsClosePosition, - id: traceId, - data: traceData, - }); - } - } - - /** - * Close multiple positions in parallel - * Batch version of closePosition() that closes multiple positions simultaneously - */ - async closePositions( - params: ClosePositionsParams, - ): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let operationResult: ClosePositionsResult | null = null; - let operationError: Error | null = null; - - try { - trace({ - name: TraceName.PerpsClosePosition, - id: traceId, - op: TraceOperation.PerpsPositionManagement, - tags: { - provider: this.state.activeProvider, - isBatch: 'true', - isTestnet: this.state.isTestnet, - }, - data: { - closeAll: params.closeAll ? 'true' : 'false', - coinCount: params.coins?.length || 0, - }, - }); - - const provider = this.getActiveProvider(); - - DevLogger.log('[closePositions] Batch method check', { - providerType: provider.protocolId, - hasBatchMethod: !!provider.closePositions, - methodType: typeof provider.closePositions, - providerKeys: Object.keys(provider).filter((k) => k.includes('close')), - }); - - // Use batch close if provider supports it (provider handles filtering) - if (provider.closePositions) { - operationResult = await provider.closePositions(params); - } else { - // Fallback: Get positions, filter, and close in parallel - const positions = await this.getPositions(); - - const positionsToClose = - params.closeAll || !params.coins || params.coins.length === 0 - ? positions - : positions.filter((p) => params.coins?.includes(p.coin)); - - if (positionsToClose.length === 0) { - operationResult = { - success: false, - successCount: 0, - failureCount: 0, - results: [], - }; - return operationResult; - } - - const results = await Promise.allSettled( - positionsToClose.map((position) => - this.closePosition({ coin: position.coin }), - ), - ); - - // Aggregate results - const successCount = results.filter( - (r) => r.status === 'fulfilled' && r.value.success, - ).length; - const failureCount = results.length - successCount; - - operationResult = { - success: successCount > 0, - successCount, - failureCount, - results: results.map((result, index) => { - let error: string | undefined; - if (result.status === 'rejected') { - error = - result.reason instanceof Error - ? result.reason.message - : 'Unknown error'; - } else if (result.status === 'fulfilled' && !result.value.success) { - error = result.value.error; - } - - return { - coin: positionsToClose[index].coin, - success: !!( - result.status === 'fulfilled' && result.value.success - ), - error, - }; - }), - }; - } - - return operationResult; - } catch (error) { - operationError = - error instanceof Error ? error : new Error(String(error)); - throw error; - } finally { - const completionDuration = performance.now() - startTime; - - // Track batch close event (success or failure) - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: - operationResult?.success && operationResult.successCount > 0 - ? PerpsEventValues.STATUS.EXECUTED - : PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - ...(operationError && { - [PerpsEventProperties.ERROR_MESSAGE]: operationError.message, - }), - // Note: Custom properties for batch tracking (totalCount, successCount, failureCount) - // can be added to PerpsEventProperties if needed for analytics - }) - .build(), - ); - - endTrace({ - name: TraceName.PerpsClosePosition, - id: traceId, - }); - } - } - - /** - * Update TP/SL for an existing position - */ - async updatePositionTPSL( - params: UpdatePositionTPSLParams, - ): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let traceData: { success: boolean; error?: string } | undefined; - let result: OrderResult | undefined; - let errorMessage: string | undefined; - - // Extract tracking data with defaults - const direction = params.trackingData?.direction; - const positionSize = params.trackingData?.positionSize; - const source = - params.trackingData?.source || PerpsEventValues.SOURCE.TP_SL_VIEW; - - try { - const traceSpan = trace({ - name: TraceName.PerpsUpdateTPSL, - id: traceId, - op: TraceOperation.PerpsPositionManagement, - tags: { - provider: this.state.activeProvider, - market: params.coin, - isTestnet: this.state.isTestnet, - }, - data: { - takeProfitPrice: params.takeProfitPrice || '', - stopLossPrice: params.stopLossPrice || '', - }, - }); - - const provider = this.getActiveProvider(); - - // Get fee discount from rewards - const feeDiscountBips = await this.calculateUserFeeDiscount(traceSpan); - - // Set discount context in provider for this operation - if (feeDiscountBips !== undefined && provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(feeDiscountBips); - } - - try { - result = await provider.updatePositionTPSL(params); - } finally { - // Always clear discount context, even on exception - if (provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(undefined); - } - } - - if (result.success) { - this.update((state) => { - state.lastUpdateTimestamp = Date.now(); - }); - traceData = { success: true }; - } else { - errorMessage = result.error || 'Unknown error'; - traceData = { success: false, error: errorMessage }; - } - - return result; - } catch (error) { - errorMessage = error instanceof Error ? error.message : 'Unknown error'; - traceData = { success: false, error: errorMessage }; - throw error; - } finally { - const completionDuration = performance.now() - startTime; - - // Build common event properties - const eventProperties = { - [PerpsEventProperties.STATUS]: result?.success - ? PerpsEventValues.STATUS.EXECUTED - : PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.ASSET]: params.coin, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.SOURCE]: source, - ...(direction && { - [PerpsEventProperties.DIRECTION]: - direction === 'long' - ? PerpsEventValues.DIRECTION.LONG - : PerpsEventValues.DIRECTION.SHORT, - }), - ...(positionSize !== undefined && { - [PerpsEventProperties.POSITION_SIZE]: positionSize, - }), - ...(params.takeProfitPrice && { - [PerpsEventProperties.TAKE_PROFIT_PRICE]: parseFloat( - params.takeProfitPrice, - ), - }), - ...(params.stopLossPrice && { - [PerpsEventProperties.STOP_LOSS_PRICE]: parseFloat( - params.stopLossPrice, - ), - }), - ...(errorMessage && { - [PerpsEventProperties.ERROR_MESSAGE]: errorMessage, - }), - }; - - // Track event once with all properties - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_RISK_MANAGEMENT, - ) - .addProperties(eventProperties) - .build(), - ); - - endTrace({ - name: TraceName.PerpsUpdateTPSL, - id: traceId, - data: traceData, - }); - } - } - - /** - * Simplified deposit method that prepares transaction for confirmation screen - * No complex state tracking - just sets a loading flag - */ - async depositWithConfirmation(amount?: string) { - const { NetworkController, TransactionController } = Engine.context; - - try { - // Clear any stale results when starting a new deposit flow - // Don't set depositInProgress yet - wait until user confirms - - // Generate deposit request ID for tracking - const currentDepositId = generateDepositId(); - - this.update((state) => { - state.lastDepositResult = null; + this.update((state) => { + state.lastDepositResult = null; // Add deposit request to tracking const depositRequest = { @@ -2635,36 +1268,6 @@ export class PerpsController extends BaseController< state.depositRequests.unshift(depositRequest); // Add to beginning of array }); - const provider = this.getActiveProvider(); - const depositRoutes = provider.getDepositRoutes({ isTestnet: false }); - const route = depositRoutes[0]; - const bridgeContractAddress = route.contractAddress; - - const transferData = generateTransferData('transfer', { - toAddress: bridgeContractAddress, - amount: '0x0', - }); - - const evmAccount = getEvmAccountFromSelectedAccountGroup(); - if (!evmAccount) { - throw new Error( - 'No EVM-compatible account found in selected account group', - ); - } - const accountAddress = evmAccount.address as Hex; - - const parsedAsset = parseCaipAssetId(route.assetId); - const assetChainId = toHex(parsedAsset.chainId.split(':')[1]); - const tokenAddress = parsedAsset.assetReference as Hex; - - const transaction: TransactionParams = { - from: accountAddress, - to: tokenAddress, - value: '0x0', - data: transferData, - gas: DEPOSIT_GAS_LIMIT, - }; - const networkClientId = NetworkController.findNetworkClientIdByChainId(assetChainId); @@ -2895,742 +1498,125 @@ export class PerpsController extends BaseController< * @returns WithdrawResult with withdrawal ID and tracking info */ async withdraw(params: WithdrawParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let traceData: - | { - success: boolean; - error?: string; - txHash?: string; - withdrawalId?: string; - } - | undefined; - - // Generate withdrawal request ID for tracking (outside try block for catch access) - const currentWithdrawalId = `withdraw-${Date.now()}-${Math.random() - .toString(36) - .substring(2, 11)}`; - - try { - trace({ - name: TraceName.PerpsWithdraw, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - assetId: params.assetId || '', - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - DevLogger.log('PerpsController: STARTING WITHDRAWAL', { - params, - timestamp: new Date().toISOString(), - assetId: params.assetId, - amount: params.amount, - destination: params.destination, - activeProvider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }); - - // Set withdrawal in progress - this.update((state) => { - state.withdrawInProgress = true; - - // Calculate net amount after fees (same logic as completed withdrawals) - const grossAmount = parseFloat(params.amount); - const feeAmount = 1.0; // HyperLiquid withdrawal fee is $1 USDC - const netAmount = Math.max(0, grossAmount - feeAmount); - - // Add withdrawal request to tracking - const withdrawalRequest = { - id: currentWithdrawalId, - timestamp: Date.now(), - amount: netAmount.toString(), // Use net amount (after fees) - asset: USDC_SYMBOL, // Default to USDC for now - success: false, // Will be updated when transaction completes - txHash: undefined, - status: 'pending' as TransactionStatus, - destination: params.destination, - transactionId: undefined, // Will be set to withdrawalId when available - }; - - state.withdrawalRequests.unshift(withdrawalRequest); // Add to beginning of array - }); - - // Get provider (all validation is handled at the provider level) - const provider = this.getActiveProvider(); - DevLogger.log('PerpsController: DELEGATING TO PROVIDER', { - provider: this.state.activeProvider, - providerReady: !!provider, - }); - - // Execute withdrawal through provider - const result = await provider.withdraw(params); - - DevLogger.log('PerpsController: WITHDRAWAL RESULT', { - success: result.success, - error: result.error, - txHash: result.txHash, - timestamp: new Date().toISOString(), - }); - - // Update state based on result - if (result.success) { - this.update((state) => { - state.lastError = null; - state.lastUpdateTimestamp = Date.now(); - state.withdrawInProgress = false; - state.lastWithdrawResult = { - success: true, - txHash: result.txHash || '', - amount: params.amount, - asset: USDC_SYMBOL, // Default asset for withdrawals - timestamp: Date.now(), - error: '', - }; - - // Update the withdrawal request by request ID to avoid race conditions - if (state.withdrawalRequests.length > 0) { - const requestToUpdate = state.withdrawalRequests.find( - (req) => req.id === currentWithdrawalId, - ); - if (requestToUpdate) { - // Set status based on success and txHash availability - if (result.txHash) { - requestToUpdate.status = 'completed' as TransactionStatus; - requestToUpdate.success = true; - requestToUpdate.txHash = result.txHash; - } else { - // Success but no txHash means it's bridging - requestToUpdate.status = 'bridging' as TransactionStatus; - requestToUpdate.success = true; - } - // Always update withdrawal ID if available - if (result.withdrawalId) { - requestToUpdate.withdrawalId = result.withdrawalId; - } - } - } - }); - - DevLogger.log('PerpsController: WITHDRAWAL SUCCESSFUL', { - txHash: result.txHash, - amount: params.amount, - assetId: params.assetId, - withdrawalId: result.withdrawalId, - }); - - // Track withdrawal transaction executed - const completionDuration = performance.now() - startTime; - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_WITHDRAWAL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, - [PerpsEventProperties.WITHDRAWAL_AMOUNT]: params.amount, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - }) - .build(), - ); - - // Note: The withdrawal result will be cleared by usePerpsWithdrawStatus hook - // after showing the appropriate toast messages - - // Trigger account state refresh after withdrawal - this.getAccountState({ source: 'post_withdrawal' }).catch((error) => { - Logger.error( - ensureError(error), - this.getErrorContext('withdraw', { - operation: 'refreshAccountState', - }), - ); - }); - - traceData = { - success: true, - txHash: result.txHash || '', - withdrawalId: result.withdrawalId || '', - }; - - return result; - } - - this.update((state) => { - state.lastError = result.error || PERPS_ERROR_CODES.WITHDRAW_FAILED; - state.lastUpdateTimestamp = Date.now(); - state.withdrawInProgress = false; - state.lastWithdrawResult = { - success: false, - error: result.error || PERPS_ERROR_CODES.WITHDRAW_FAILED, - amount: params.amount, - asset: USDC_SYMBOL, // Default asset for withdrawals - timestamp: Date.now(), - txHash: '', - }; - - // Update the withdrawal request by request ID to avoid race conditions - if (state.withdrawalRequests.length > 0) { - const requestToUpdate = state.withdrawalRequests.find( - (req) => req.id === currentWithdrawalId, - ); - if (requestToUpdate) { - requestToUpdate.status = 'failed' as TransactionStatus; - requestToUpdate.success = false; - } - } - }); - - DevLogger.log('PerpsController: WITHDRAWAL FAILED', { - error: result.error, - params, - }); - - // Track withdrawal transaction failed - const completionDuration = performance.now() - startTime; - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_WITHDRAWAL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.WITHDRAWAL_AMOUNT]: params.amount, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: - result.error || 'Unknown error', - }) - .build(), - ); - - traceData = { - success: false, - error: result.error || 'Unknown error', - }; - - return result; - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : PERPS_ERROR_CODES.WITHDRAW_FAILED; - - Logger.error( - ensureError(error), - this.getErrorContext('withdraw', { - assetId: params.assetId, - amount: params.amount, - }), - ); - - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - state.withdrawInProgress = false; - state.lastWithdrawResult = { - success: false, - error: errorMessage, - amount: '0', // Unknown amount for pre-confirmation errors - asset: USDC_SYMBOL, // Default asset for withdrawals - timestamp: Date.now(), - txHash: '', - }; - - // Update the withdrawal request by request ID to avoid race conditions - if (state.withdrawalRequests.length > 0) { - const requestToUpdate = state.withdrawalRequests.find( - (req) => req.id === currentWithdrawalId, - ); - if (requestToUpdate) { - requestToUpdate.status = 'failed' as TransactionStatus; - requestToUpdate.success = false; - } - } - }); - - // Track withdrawal transaction failed (catch block) - const completionDuration = performance.now() - startTime; - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.PERPS_WITHDRAWAL_TRANSACTION, - ) - .addProperties({ - [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, - [PerpsEventProperties.WITHDRAWAL_AMOUNT]: params.amount, - [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, - [PerpsEventProperties.ERROR_MESSAGE]: errorMessage, - }) - .build(), - ); - - traceData = { - success: false, - error: errorMessage, - }; + const provider = this.getActiveProvider(); - return { success: false, error: errorMessage }; - } finally { - endTrace({ - name: TraceName.PerpsWithdraw, - id: traceId, - data: traceData, - }); - } + return AccountService.withdraw({ + provider, + params, + context: this.createServiceContext('withdraw'), + refreshAccountState: async () => { + await this.getAccountState({ source: 'post_withdrawal' }); + }, + }); } /** * Get current positions + * Thin delegation to MarketDataService */ async getPositions(params?: GetPositionsParams): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsGetPositions, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - - const provider = this.getActiveProvider(); - const positions = await provider.getPositions(params); - - // Only update state if the provider call succeeded - this.update((state) => { - state.lastUpdateTimestamp = Date.now(); - state.lastError = null; // Clear any previous errors - }); - - traceData = { success: true }; - return positions; - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : PERPS_ERROR_CODES.POSITIONS_FAILED; - - Logger.error(ensureError(error), this.getErrorContext('getPositions')); - - // Update error state but don't modify positions (keep existing data) - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - }); - - traceData = { - success: false, - error: errorMessage, - }; - - // Re-throw the error so components can handle it appropriately - throw error; - } finally { - endTrace({ - name: TraceName.PerpsGetPositions, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.getPositions({ + provider, + params, + context: this.createServiceContext('getPositions'), + }); } /** * Get historical user fills (trade executions) + * Thin delegation to MarketDataService */ async getOrderFills(params?: GetOrderFillsParams): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsOrderFillsFetch, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.getOrderFills(params); - - traceData = { success: true }; - return result; - } catch (error) { - Logger.error(ensureError(error), this.getErrorContext('getOrderFills')); - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - endTrace({ - name: TraceName.PerpsOrderFillsFetch, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.getOrderFills({ + provider, + params, + context: this.createServiceContext('getOrderFills'), + }); } /** * Get historical user orders (order lifecycle) + * Thin delegation to MarketDataService */ async getOrders(params?: GetOrdersParams): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsOrdersFetch, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.getOrders(params); - - traceData = { success: true }; - return result; - } catch (error) { - Logger.error(ensureError(error), this.getErrorContext('getOrders')); - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - endTrace({ - name: TraceName.PerpsOrdersFetch, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.getOrders({ + provider, + params, + context: this.createServiceContext('getOrders'), + }); } /** * Get currently open orders (real-time status) + * Thin delegation to MarketDataService */ async getOpenOrders(params?: GetOrdersParams): Promise { - const traceId = uuidv4(); - const startTime = performance.now(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - const traceSpan = trace({ - name: TraceName.PerpsOrdersFetch, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.getOpenOrders(params); - - const completionDuration = performance.now() - startTime; - setMeasurement( - PerpsMeasurementName.PERPS_GET_OPEN_ORDERS_OPERATION, - completionDuration, - 'millisecond', - traceSpan, - ); - - traceData = { success: true }; - return result; - } catch (error) { - Logger.error(ensureError(error), this.getErrorContext('getOpenOrders')); - - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - endTrace({ - name: TraceName.PerpsOrdersFetch, - id: traceId, - data: traceData, - }); - } - } - - /** - * Get historical user funding history (funding payments) - */ - async getFunding(params?: GetFundingParams): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsFundingFetch, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.getFunding(params); - - traceData = { success: true }; - return result; - } catch (error) { - Logger.error(ensureError(error), this.getErrorContext('getFunding')); + const provider = this.getActiveProvider(); + return MarketDataService.getOpenOrders({ + provider, + params, + context: this.createServiceContext('getOpenOrders'), + }); + } - traceData = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - throw error; - } finally { - endTrace({ - name: TraceName.PerpsFundingFetch, - id: traceId, - data: traceData, - }); - } + /** + * Get historical user funding history (funding payments) + * Thin delegation to MarketDataService + */ + async getFunding(params?: GetFundingParams): Promise { + const provider = this.getActiveProvider(); + return MarketDataService.getFunding({ + provider, + params, + context: this.createServiceContext('getFunding'), + }); } /** * Get account state (balances, etc.) + * Thin delegation to MarketDataService */ async getAccountState(params?: GetAccountStateParams): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsGetAccountState, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - source: params?.source || 'unknown', - }, - }); - - const provider = this.getActiveProvider(); - - // Get both current account state and historical portfolio data - const [accountState, historicalPortfolio] = await Promise.all([ - provider.getAccountState(params), - provider.getHistoricalPortfolio(params).catch((error) => { - Logger.error( - ensureError(error), - this.getErrorContext('getAccountState', { - operation: 'getHistoricalPortfolio', - }), - ); - }), - ]); - - // Add safety check for accountState to prevent TypeError - if (!accountState) { - const error = new Error( - 'Failed to get account state: received null/undefined response', - ); - - // Track null account state errors in Sentry for API monitoring - Logger.error( - ensureError(error), - this.getErrorContext('getAccountState', { - operation: 'nullAccountStateCheck', - }), - ); - - throw error; - } - - // fallback to the current account total value if possible - const historicalPortfolioToUse: HistoricalPortfolioResult = - historicalPortfolio ?? { - accountValue1dAgo: accountState.totalBalance || '0', - timestamp: 0, - }; - - // Only update state if the provider call succeeded - DevLogger.log( - 'PerpsController: Updating Redux store with accountState and historical data:', - { accountState, historicalPortfolio: historicalPortfolioToUse }, - ); - - this.update((state) => { - state.accountState = accountState; - state.lastUpdateTimestamp = Date.now(); - state.lastError = null; // Clear any previous errors - }); - DevLogger.log('PerpsController: Redux store updated successfully'); - - traceData = { success: true }; - return accountState; - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : PERPS_ERROR_CODES.ACCOUNT_STATE_FAILED; - - // Update error state but don't modify accountState (keep existing data) - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - }); - - traceData = { - success: false, - error: errorMessage, - }; - - // Re-throw the error so components can handle it appropriately - throw error; - } finally { - endTrace({ - name: TraceName.PerpsGetAccountState, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.getAccountState({ + provider, + params, + context: this.createServiceContext('getAccountState'), + }); } /** - * Get historical portfolio data for percentage calculations + * Get historical portfolio data + * Thin delegation to MarketDataService */ async getHistoricalPortfolio( params?: GetHistoricalPortfolioParams, ): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsGetHistoricalPortfolio, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - }, - }); - - const provider = this.getActiveProvider(); - const result = await provider.getHistoricalPortfolio(params); - - // Return the result without storing it in state - // Historical data can be fetched when needed - - traceData = { success: true }; - return result; - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Failed to get historical portfolio'; - - Logger.error( - ensureError(error), - this.getErrorContext('getHistoricalPortfolio'), - ); - - // Update error state - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - }); - - traceData = { - success: false, - error: errorMessage, - }; - - // Re-throw the error so components can handle it appropriately - throw error; - } finally { - endTrace({ - name: TraceName.PerpsGetHistoricalPortfolio, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.getHistoricalPortfolio({ + provider, + params, + context: this.createServiceContext('getHistoricalPortfolio'), + }); } /** * Get available markets with optional filtering - * Delegates to provider which handles all multi-DEX logic transparently - * @param params - Optional parameters for filtering (symbols, dex) + * Thin delegation to MarketDataService */ async getMarkets(params?: { symbols?: string[]; dex?: string; }): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsGetMarkets, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - ...(params?.symbols && { symbolCount: params.symbols.length }), - ...(params?.dex !== undefined && { dex: params.dex }), - }, - }); - - const provider = this.getActiveProvider(); - const markets = await provider.getMarkets(params); - - // Clear any previous errors on successful call - this.update((state) => { - state.lastError = null; - state.lastUpdateTimestamp = Date.now(); - }); - - traceData = { success: true }; - return markets; - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : PERPS_ERROR_CODES.MARKETS_FAILED; - - Logger.error(ensureError(error), this.getErrorContext('getMarkets')); - - // Update error state - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - }); - - traceData = { - success: false, - error: errorMessage, - }; - - // Re-throw the error so components can handle it appropriately - throw error; - } finally { - endTrace({ - name: TraceName.PerpsGetMarkets, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.getMarkets({ + provider, + params, + context: this.createServiceContext('getMarkets'), + }); } /** @@ -3640,97 +1626,26 @@ export class PerpsController extends BaseController< */ async getAvailableDexs(params?: GetAvailableDexsParams): Promise { const provider = this.getActiveProvider(); - - if (!provider.getAvailableDexs) { - throw new Error('Provider does not support HIP-3 DEXs'); - } - - return provider.getAvailableDexs(params); + return MarketDataService.getAvailableDexs({ provider, params }); } /** * Fetch historical candle data + * Thin delegation to MarketDataService */ async fetchHistoricalCandles( coin: string, interval: CandlePeriod, limit: number = 100, ): Promise { - const traceId = uuidv4(); - let traceData: { success: boolean; error?: string } | undefined; - - try { - trace({ - name: TraceName.PerpsFetchHistoricalCandles, - id: traceId, - op: TraceOperation.PerpsOperation, - tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - coin, - interval, - }, - }); - - const provider = this.getActiveProvider() as IPerpsProvider & { - clientService?: { - fetchHistoricalCandles: ( - coin: string, - interval: CandlePeriod, - limit: number, - ) => Promise; - }; - }; - - // Check if provider has a client service with fetchHistoricalCandles - if (provider.clientService?.fetchHistoricalCandles) { - const result = await provider.clientService.fetchHistoricalCandles( - coin, - interval, - limit, - ); - - traceData = { success: true }; - return result; - } - - // Fallback: throw error if method not available - throw new Error('Historical candles not supported by current provider'); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Failed to fetch historical candles'; - - Logger.error( - ensureError(error), - this.getErrorContext('fetchHistoricalCandles', { - coin, - interval, - limit, - }), - ); - - // Update error state - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - }); - - traceData = { - success: false, - error: errorMessage, - }; - - // Re-throw the error so components can handle it appropriately - throw error; - } finally { - endTrace({ - name: TraceName.PerpsFetchHistoricalCandles, - id: traceId, - data: traceData, - }); - } + const provider = this.getActiveProvider(); + return MarketDataService.fetchHistoricalCandles({ + provider, + coin, + interval, + limit, + context: this.createServiceContext('fetchHistoricalCandles'), + }); } /** @@ -3741,7 +1656,7 @@ export class PerpsController extends BaseController< params: LiquidationPriceParams, ): Promise { const provider = this.getActiveProvider(); - return provider.calculateLiquidationPrice(params); + return MarketDataService.calculateLiquidationPrice({ provider, params }); } /** @@ -3752,7 +1667,7 @@ export class PerpsController extends BaseController< params: MaintenanceMarginParams, ): Promise { const provider = this.getActiveProvider(); - return provider.calculateMaintenanceMargin(params); + return MarketDataService.calculateMaintenanceMargin({ provider, params }); } /** @@ -3760,7 +1675,7 @@ export class PerpsController extends BaseController< */ async getMaxLeverage(asset: string): Promise { const provider = this.getActiveProvider(); - return provider.getMaxLeverage(asset); + return MarketDataService.getMaxLeverage({ provider, asset }); } /** @@ -3770,7 +1685,7 @@ export class PerpsController extends BaseController< params: OrderParams, ): Promise<{ isValid: boolean; error?: string }> { const provider = this.getActiveProvider(); - return provider.validateOrder(params); + return MarketDataService.validateOrder({ provider, params }); } /** @@ -3780,7 +1695,7 @@ export class PerpsController extends BaseController< params: ClosePositionParams, ): Promise<{ isValid: boolean; error?: string }> { const provider = this.getActiveProvider(); - return provider.validateClosePosition(params); + return MarketDataService.validateClosePosition({ provider, params }); } /** @@ -3790,7 +1705,7 @@ export class PerpsController extends BaseController< params: WithdrawParams, ): Promise<{ isValid: boolean; error?: string }> { const provider = this.getActiveProvider(); - return provider.validateWithdrawal(params); + return AccountService.validateWithdrawal({ provider, params }); } /** @@ -3799,7 +1714,7 @@ export class PerpsController extends BaseController< getWithdrawalRoutes(): AssetRoute[] { try { const provider = this.getActiveProvider(); - return provider.getWithdrawalRoutes(); + return MarketDataService.getWithdrawalRoutes({ provider }); } catch (error) { Logger.error( ensureError(error), @@ -4052,7 +1967,7 @@ export class PerpsController extends BaseController< params: FeeCalculationParams, ): Promise { const provider = this.getActiveProvider(); - return provider.calculateFees(params); + return MarketDataService.calculateFees({ provider, params }); } /** @@ -4092,108 +2007,28 @@ export class PerpsController extends BaseController< * Returned in Country or Country-Region format * Example: FR, DE, US-MI, CA-ON */ - async #fetchGeoLocation(): Promise { - // Check cache first - if (this.geoLocationCache) { - const cacheAge = Date.now() - this.geoLocationCache.timestamp; - if (cacheAge < this.GEO_CACHE_TTL_MS) { - DevLogger.log('PerpsController: Using cached geo location', { - location: this.geoLocationCache.location, - cacheAge: `${(cacheAge / 1000).toFixed(1)}s`, - }); - return this.geoLocationCache.location; - } - } - - // If already fetching, return the existing promise - if (this.geoLocationFetchPromise) { - DevLogger.log( - 'PerpsController: Geo location fetch already in progress, waiting...', - ); - return this.geoLocationFetchPromise; - } - - // Start new fetch - this.geoLocationFetchPromise = this.#performGeoLocationFetch(); - - try { - const location = await this.geoLocationFetchPromise; - return location; - } finally { - // Clear the promise after completion (success or failure) - this.geoLocationFetchPromise = null; - } - } - - /** - * Perform the actual geo location fetch - * Separated to allow proper promise management - */ - async #performGeoLocationFetch(): Promise { - let location = 'UNKNOWN'; - - try { - const environment = getEnvironment(); - - DevLogger.log('PerpsController: Fetching geo location from API', { - environment, - }); - - const response = await successfulFetch( - ON_RAMP_GEO_BLOCKING_URLS[environment], - ); - - const textResult = await response?.text(); - location = textResult || 'UNKNOWN'; - - // Cache the successful result - this.geoLocationCache = { - location, - timestamp: Date.now(), - }; - - DevLogger.log('PerpsController: Geo location fetched successfully', { - location, - }); - - return location; - } catch (e) { - Logger.error( - ensureError(e), - this.getErrorContext('performGeoLocationFetch'), - ); - // Don't cache failures - return location; - } - } - /** * Refresh eligibility status */ async refreshEligibility(): Promise { - // Default to false in case of error. - let isEligible = true; - try { DevLogger.log('PerpsController: Refreshing eligibility'); - // Returns UNKNOWN if we can't fetch the geo location - const geoLocation = await this.#fetchGeoLocation(); + const isEligible = await EligibilityService.checkEligibility( + this.blockedRegionList.list, + ); - // Only set to eligible if we have valid geolocation and it's not blocked - if (geoLocation !== 'UNKNOWN') { - isEligible = this.blockedRegionList.list.every( - (geoBlockedRegion) => !geoLocation.startsWith(geoBlockedRegion), - ); - } + this.update((state) => { + state.isEligible = isEligible; + }); } catch (error) { Logger.error( ensureError(error), this.getErrorContext('refreshEligibility'), ); - } finally { + // Default to eligible on error this.update((state) => { - state.isEligible = isEligible; + state.isEligible = true; }); } } @@ -4205,7 +2040,7 @@ export class PerpsController extends BaseController< */ getBlockExplorerUrl(address?: string): string { const provider = this.getActiveProvider(); - return provider.getBlockExplorerUrl(address); + return MarketDataService.getBlockExplorerUrl({ provider, address }); } /** @@ -4389,8 +2224,9 @@ export class PerpsController extends BaseController< /** * Report order events to data lake API with retry (non-blocking) + * Thin delegation to DataLakeService */ - private async reportOrderToDataLake(params: { + protected async reportOrderToDataLake(params: { action: 'open' | 'close'; coin: string; sl_price?: number; @@ -4398,198 +2234,18 @@ export class PerpsController extends BaseController< retryCount?: number; _traceId?: string; }): Promise<{ success: boolean; error?: string }> { - // Skip data lake reporting for testnet as the API doesn't handle testnet data - const isTestnet = this.state.isTestnet; - if (isTestnet) { - DevLogger.log('DataLake API: Skipping for testnet', { - action: params.action, - coin: params.coin, - network: 'testnet', - }); - return { success: true, error: 'Skipped for testnet' }; - } - - const MAX_RETRIES = 3; - const RETRY_DELAY_MS = 1000; - const { - action, - coin, - sl_price, - tp_price, - retryCount = 0, - _traceId, - } = params; - - // Generate trace ID once on first call - const traceId = _traceId || uuidv4(); - - // Start trace only on first attempt - let traceSpan: Span | undefined; - if (retryCount === 0) { - traceSpan = trace({ - name: TraceName.PerpsDataLakeReport, - op: TraceOperation.PerpsOperation, - id: traceId, - tags: { - action, - coin, - }, - }); - } - - // Log the attempt - DevLogger.log('DataLake API: Starting order report', { - action, - coin, - attempt: retryCount + 1, - maxAttempts: MAX_RETRIES + 1, - hasStopLoss: !!sl_price, - hasTakeProfit: !!tp_price, - timestamp: new Date().toISOString(), + return DataLakeService.reportOrder({ + action: params.action, + coin: params.coin, + sl_price: params.sl_price, + tp_price: params.tp_price, + isTestnet: this.state.isTestnet, + context: this.createServiceContext('reportOrderToDataLake', { + messenger: this.messenger, + }), + retryCount: params.retryCount, + _traceId: params._traceId, }); - - const apiCallStartTime = performance.now(); - - try { - const token = await this.messenger.call( - 'AuthenticationController:getBearerToken', - ); - const evmAccount = getEvmAccountFromSelectedAccountGroup(); - - if (!evmAccount || !token) { - DevLogger.log('DataLake API: Missing requirements', { - hasAccount: !!evmAccount, - hasToken: !!token, - action, - coin, - }); - return { success: false, error: 'No account or token available' }; - } - - const response = await fetch(DATA_LAKE_API_CONFIG.ORDERS_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - user_id: evmAccount.address, - coin, - sl_price, - tp_price, - }), - }); - - if (!response.ok) { - throw new Error(`DataLake API error: ${response.status}`); - } - - // Consume response body (might be empty for 201, but good to check) - const responseBody = await response.text(); - - const apiCallDuration = performance.now() - apiCallStartTime; - - // Add measurement to trace if span exists - if (traceSpan) { - setMeasurement( - PerpsMeasurementName.PERPS_DATA_LAKE_API_CALL, - apiCallDuration, - 'millisecond', - traceSpan, - ); - } - - // Success logging - DevLogger.log('DataLake API: Order reported successfully', { - action, - coin, - status: response.status, - attempt: retryCount + 1, - responseBody: responseBody || 'empty', - duration: `${apiCallDuration.toFixed(0)}ms`, - }); - - // End trace on success - endTrace({ - name: TraceName.PerpsDataLakeReport, - id: traceId, - data: { - success: true, - retries: retryCount, - }, - }); - - return { success: true }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - - Logger.error( - ensureError(error), - this.getErrorContext('reportOrderToDataLake', { - action, - coin, - retryCount, - willRetry: retryCount < MAX_RETRIES, - }), - ); - - // Retry logic - if (retryCount < MAX_RETRIES) { - const retryDelay = RETRY_DELAY_MS * Math.pow(2, retryCount); - DevLogger.log('DataLake API: Scheduling retry', { - retryIn: `${retryDelay}ms`, - nextAttempt: retryCount + 2, - action, - coin, - }); - - setTimeout(() => { - this.reportOrderToDataLake({ - action, - coin, - sl_price, - tp_price, - retryCount: retryCount + 1, - _traceId: traceId, - }).catch((err) => { - Logger.error( - ensureError(err), - this.getErrorContext('reportOrderToDataLake', { - operation: 'retry', - retryCount: retryCount + 1, - action, - coin, - }), - ); - }); - }, retryDelay); - - return { success: false, error: errorMessage }; - } - - endTrace({ - name: TraceName.PerpsDataLakeReport, - id: traceId, - data: { - success: false, - error: errorMessage, - totalRetries: retryCount, - }, - }); - - Logger.error( - ensureError(error), - this.getErrorContext('reportOrderToDataLake', { - operation: 'finalFailure', - action, - coin, - retryCount, - }), - ); - - return { success: false, error: errorMessage }; - } } /** diff --git a/app/components/UI/Perps/controllers/services/AccountService.test.ts b/app/components/UI/Perps/controllers/services/AccountService.test.ts new file mode 100644 index 00000000000..e7ec9610047 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/AccountService.test.ts @@ -0,0 +1,634 @@ +import { AccountService } from './AccountService'; +import { createMockServiceContext } from '../../__mocks__/serviceMocks'; +import { createMockHyperLiquidProvider } from '../../__mocks__/providerMocks'; +import Logger from '../../../../../util/Logger'; +import { trace, endTrace } from '../../../../../util/trace'; +import type { ServiceContext } from './ServiceContext'; +import type { IPerpsProvider, WithdrawParams, WithdrawResult } from '../types'; +import type { PerpsControllerState } from '../PerpsController'; +import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; + +jest.mock('../../../../../util/Logger'); +jest.mock('../../../../../util/trace'); +jest.mock('uuid', () => ({ v4: () => 'mock-withdrawal-trace-id' })); +jest.mock('react-native-performance', () => ({ + now: jest.fn(() => 1000), +})); +jest.mock('../../../../../core/Analytics/MetricsEventBuilder', () => ({ + MetricsEventBuilder: { + createEventBuilder: jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ event: 'mock-event' }), + })), + }, +})); +jest.mock('../../../../../core/Analytics', () => ({ + MetaMetricsEvents: { + PERPS_WITHDRAWAL_TRANSACTION: 'PERPS_WITHDRAWAL_TRANSACTION', + }, +})); +jest.mock('../../constants/eventNames', () => ({ + PerpsEventProperties: { + STATUS: 'status', + WITHDRAWAL_AMOUNT: 'withdrawal_amount', + COMPLETION_DURATION: 'completion_duration', + ERROR_MESSAGE: 'error_message', + }, + PerpsEventValues: { + STATUS: { + EXECUTED: 'executed', + FAILED: 'failed', + }, + }, +})); +jest.mock('../../constants/hyperLiquidConfig', () => ({ + USDC_SYMBOL: 'USDC', +})); +jest.mock('../perpsErrorCodes', () => ({ + PERPS_ERROR_CODES: { + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + }, +})); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { + log: jest.fn(), + }, +})); + +describe('AccountService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockRefreshAccountState: jest.Mock; + + const mockWithdrawParams: WithdrawParams = { + assetId: 'eip155:42161/erc20:0xTokenAddress/default', + amount: '100', + destination: '0xDestination', + }; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockContext = createMockServiceContext({ + errorContext: { controller: 'AccountService', method: 'test' }, + }); + mockRefreshAccountState = jest.fn().mockResolvedValue(undefined); + + jest.clearAllMocks(); + + // Mock Date.now() to return a stable timestamp + jest.spyOn(Date, 'now').mockReturnValue(1234567890000); + + // Reinitialize MetricsEventBuilder mock after clearAllMocks + (MetricsEventBuilder.createEventBuilder as jest.Mock).mockImplementation( + () => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ event: 'mock-event' }), + }), + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('withdraw', () => { + it('executes successful withdrawal with tx hash', async () => { + const mockResult: WithdrawResult = { + success: true, + txHash: '0xTransactionHash', + withdrawalId: 'withdrawal-123', + }; + mockProvider.withdraw.mockResolvedValue(mockResult); + + const result = await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.withdraw).toHaveBeenCalledWith(mockWithdrawParams); + }); + + it('starts trace with correct parameters', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + tags: expect.objectContaining({ + assetId: mockWithdrawParams.assetId, + provider: 'hyperliquid', + isTestnet: false, + }), + }), + ); + }); + + it('ends trace on successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + withdrawalId: 'withdrawal-123', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + data: expect.objectContaining({ + success: true, + txHash: '0xHash', + withdrawalId: 'withdrawal-123', + }), + }), + ); + }); + + it('sets withdrawal in progress state before provider call', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('calculates net amount after $1 USDC fee', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: { ...mockWithdrawParams, amount: '100' }, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0].amount).toBe('99'); + }); + + it('creates withdrawal request with pending status', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0]).toEqual( + expect.objectContaining({ + status: 'pending', + success: false, + asset: 'USDC', + destination: mockWithdrawParams.destination, + }), + ); + }); + + it('updates state with completed status when tx hash provided', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xTransactionHash', + withdrawalId: 'withdrawal-123', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + const successUpdateCall = updateCalls[1][0]; + const mockState = { + withdrawInProgress: true, + withdrawalRequests: [{ id: expect.any(String), status: 'pending' }], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + mockState.withdrawalRequests[0].id = + mockState.withdrawalRequests[0].id || ''; + successUpdateCall(mockState); + + expect(mockState.withdrawInProgress).toBe(false); + expect(mockState.lastWithdrawResult).toEqual( + expect.objectContaining({ + success: true, + txHash: '0xTransactionHash', + }), + ); + }); + + it('updates state with bridging status when no tx hash', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + withdrawalId: 'withdrawal-123', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + expect(updateCalls.length).toBeGreaterThan(1); + }); + + it('triggers account refresh after successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockRefreshAccountState).toHaveBeenCalledTimes(1); + }); + + it('tracks analytics event on successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'mock-event', + }), + ); + }); + + it('handles withdrawal failure from provider', async () => { + const mockResult: WithdrawResult = { + success: false, + error: 'Insufficient balance', + }; + mockProvider.withdraw.mockResolvedValue(mockResult); + + const result = await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result).toEqual(mockResult); + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient balance'); + }); + + it('updates state with failed status on provider failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + const failureUpdateCall = updateCalls[updateCalls.length - 1][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: true, + withdrawalRequests: [ + { + id: expect.any(String) as string, + status: 'pending', + success: false, + amount: '100', + asset: 'USDC', + timestamp: Date.now(), + }, + ], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + failureUpdateCall(mockState); + + expect(mockState.withdrawInProgress).toBe(false); + expect(mockState.lastError).toBe('Insufficient balance'); + expect(mockState.lastWithdrawResult?.success).toBe(false); + }); + + it('tracks analytics event on withdrawal failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + + it('does not trigger account refresh on failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockRefreshAccountState).not.toHaveBeenCalled(); + }); + + it('handles exception during withdrawal', async () => { + const error = new Error('Network error'); + mockProvider.withdraw.mockRejectedValue(error); + + const result = await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('logs error on exception', async () => { + const error = new Error('Network error'); + mockProvider.withdraw.mockRejectedValue(error); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it('updates state with error on exception', async () => { + mockProvider.withdraw.mockRejectedValue(new Error('Network error')); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + const errorUpdateCall = updateCalls[updateCalls.length - 1][0]; + const mockState = { + withdrawInProgress: true, + withdrawalRequests: [ + { id: expect.any(String), status: 'pending', success: false }, + ], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + errorUpdateCall(mockState); + + expect(mockState.lastError).toBe('Network error'); + expect(mockState.withdrawInProgress).toBe(false); + }); + + it('ends trace with error data on exception', async () => { + mockProvider.withdraw.mockRejectedValue(new Error('Network error')); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + data: expect.objectContaining({ + success: false, + error: 'Network error', + }), + }), + ); + }); + + it('handles refresh account state error gracefully', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + mockRefreshAccountState.mockRejectedValue(new Error('Refresh failed')); + + const result = await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result.success).toBe(true); + }); + + it('generates unique withdrawal ID for tracking', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await AccountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0].id).toMatch( + /^withdraw-\d+-[a-z0-9]+$/, + ); + }); + }); + + describe('validateWithdrawal', () => { + it('delegates to provider validateWithdrawal', async () => { + const mockValidation = { isValid: true }; + mockProvider.validateWithdrawal.mockResolvedValue(mockValidation); + + const result = await AccountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }); + + expect(result).toEqual(mockValidation); + expect(mockProvider.validateWithdrawal).toHaveBeenCalledWith( + mockWithdrawParams, + ); + }); + + it('returns invalid when provider validation fails', async () => { + const mockValidation = { + isValid: false, + error: 'Amount exceeds balance', + }; + mockProvider.validateWithdrawal.mockResolvedValue(mockValidation); + + const result = await AccountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Amount exceeds balance'); + }); + + it('throws error on exception', async () => { + const error = new Error('Validation error'); + mockProvider.validateWithdrawal.mockRejectedValue(error); + + await expect( + AccountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }), + ).rejects.toThrow('Validation error'); + }); + + it('logs error on exception', async () => { + const error = new Error('Validation error'); + mockProvider.validateWithdrawal.mockRejectedValue(error); + + await expect( + AccountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }), + ).rejects.toThrow(); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/AccountService.ts b/app/components/UI/Perps/controllers/services/AccountService.ts new file mode 100644 index 00000000000..09f5c38cb57 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/AccountService.ts @@ -0,0 +1,352 @@ +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import type { ServiceContext } from './ServiceContext'; +import type { IPerpsProvider, WithdrawParams, WithdrawResult } from '../types'; +import type { TransactionStatus } from '../../types/transactionTypes'; +import { v4 as uuidv4 } from 'uuid'; +import { + trace, + TraceName, + TraceOperation, + endTrace, +} from '../../../../../util/trace'; +import performance from 'react-native-performance'; +import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; +import { USDC_SYMBOL } from '../../constants/hyperLiquidConfig'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; + +/** + * AccountService + * + * Handles account operations (deposits, withdrawals). + * Stateless service that delegates to provider. + * Controller handles state updates and analytics. + */ +export class AccountService { + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'AccountService', + method, + ...additionalContext, + }; + } + + /** + * Withdraw funds with full orchestration + * Handles tracing, state management, analytics, and account refresh + */ + static async withdraw(options: { + provider: IPerpsProvider; + params: WithdrawParams; + context: ServiceContext; + refreshAccountState: () => Promise; + }): Promise { + const { provider, params, context, refreshAccountState } = options; + + const traceId = uuidv4(); + const startTime = performance.now(); + let traceData: + | { + success: boolean; + error?: string; + txHash?: string; + withdrawalId?: string; + } + | undefined; + + // Generate withdrawal request ID for tracking + const currentWithdrawalId = `withdraw-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 11)}`; + + try { + trace({ + name: TraceName.PerpsWithdraw, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + assetId: params.assetId || '', + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + DevLogger.log('AccountService: STARTING WITHDRAWAL', { + params, + timestamp: new Date().toISOString(), + assetId: params.assetId, + amount: params.amount, + destination: params.destination, + activeProvider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }); + + // Set withdrawal in progress + if (context.stateManager) { + context.stateManager.update((state) => { + state.withdrawInProgress = true; + + // Calculate net amount after fees + const grossAmount = parseFloat(params.amount); + const feeAmount = 1.0; // HyperLiquid withdrawal fee is $1 USDC + const netAmount = Math.max(0, grossAmount - feeAmount); + + // Add withdrawal request to tracking + const withdrawalRequest = { + id: currentWithdrawalId, + timestamp: Date.now(), + amount: netAmount.toString(), // Use net amount (after fees) + asset: USDC_SYMBOL, + success: false, // Will be updated when transaction completes + txHash: undefined, + status: 'pending' as TransactionStatus, + destination: params.destination, + transactionId: undefined, // Will be set to withdrawalId when available + }; + + state.withdrawalRequests.unshift(withdrawalRequest); + }); + } + + DevLogger.log('AccountService: DELEGATING TO PROVIDER', { + provider: context.tracingContext.provider, + providerReady: !!provider, + }); + + // Execute withdrawal + const result = await provider.withdraw(params); + + DevLogger.log('AccountService: WITHDRAWAL RESULT', { + success: result.success, + error: result.error, + txHash: result.txHash, + timestamp: new Date().toISOString(), + }); + + // Update state based on result + if (result.success) { + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + state.withdrawInProgress = false; + state.lastWithdrawResult = { + success: true, + txHash: result.txHash || '', + amount: params.amount, + asset: USDC_SYMBOL, + timestamp: Date.now(), + error: '', + }; + + // Update the withdrawal request by request ID + if (state.withdrawalRequests.length > 0) { + const requestToUpdate = state.withdrawalRequests.find( + (req) => req.id === currentWithdrawalId, + ); + if (requestToUpdate) { + if (result.txHash) { + requestToUpdate.status = 'completed' as TransactionStatus; + requestToUpdate.success = true; + requestToUpdate.txHash = result.txHash; + } else { + requestToUpdate.status = 'bridging' as TransactionStatus; + requestToUpdate.success = true; + } + if (result.withdrawalId) { + requestToUpdate.withdrawalId = result.withdrawalId; + } + } + } + }); + } + + DevLogger.log('AccountService: WITHDRAWAL SUCCESSFUL', { + txHash: result.txHash, + amount: params.amount, + assetId: params.assetId, + withdrawalId: result.withdrawalId, + }); + + // Track withdrawal transaction executed + const completionDuration = performance.now() - startTime; + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_WITHDRAWAL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, + [PerpsEventProperties.WITHDRAWAL_AMOUNT]: params.amount, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + }); + context.analytics.trackEvent(eventBuilder.build()); + + // Trigger account state refresh after withdrawal + refreshAccountState().catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('withdraw', { + operation: 'refreshAccountState', + }), + ); + }); + + traceData = { + success: true, + txHash: result.txHash || '', + withdrawalId: result.withdrawalId || '', + }; + + return result; + } + + // Handle failure + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = result.error || PERPS_ERROR_CODES.WITHDRAW_FAILED; + state.lastUpdateTimestamp = Date.now(); + state.withdrawInProgress = false; + state.lastWithdrawResult = { + success: false, + error: result.error || PERPS_ERROR_CODES.WITHDRAW_FAILED, + amount: params.amount, + asset: USDC_SYMBOL, + timestamp: Date.now(), + txHash: '', + }; + + // Update the withdrawal request by request ID + if (state.withdrawalRequests.length > 0) { + const requestToUpdate = state.withdrawalRequests.find( + (req) => req.id === currentWithdrawalId, + ); + if (requestToUpdate) { + requestToUpdate.status = 'failed' as TransactionStatus; + requestToUpdate.success = false; + } + } + }); + } + + DevLogger.log('AccountService: WITHDRAWAL FAILED', { + error: result.error, + params, + }); + + // Track withdrawal transaction failed + const completionDuration = performance.now() - startTime; + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_WITHDRAWAL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.WITHDRAWAL_AMOUNT]: params.amount, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.ERROR_MESSAGE]: result.error || 'Unknown error', + }); + context.analytics.trackEvent(eventBuilder.build()); + + traceData = { + success: false, + error: result.error || 'Unknown error', + }; + + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.WITHDRAW_FAILED; + + Logger.error( + ensureError(error), + this.getErrorContext('withdraw', { + assetId: params.assetId, + amount: params.amount, + }), + ); + + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + state.withdrawInProgress = false; + state.lastWithdrawResult = { + success: false, + error: errorMessage, + amount: '0', + asset: USDC_SYMBOL, + timestamp: Date.now(), + txHash: '', + }; + + // Update the withdrawal request by request ID + if (state.withdrawalRequests.length > 0) { + const requestToUpdate = state.withdrawalRequests.find( + (req) => req.id === currentWithdrawalId, + ); + if (requestToUpdate) { + requestToUpdate.status = 'failed' as TransactionStatus; + requestToUpdate.success = false; + } + } + }); + } + + // Track withdrawal transaction failed (catch block) + const completionDuration = performance.now() - startTime; + + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_WITHDRAWAL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.WITHDRAWAL_AMOUNT]: params.amount, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.ERROR_MESSAGE]: errorMessage, + }); + context.analytics.trackEvent(eventBuilder.build()); + + traceData = { + success: false, + error: errorMessage, + }; + + return { success: false, error: errorMessage }; + } finally { + endTrace({ + name: TraceName.PerpsWithdraw, + id: traceId, + data: traceData, + }); + } + } + + /** + * Validate withdrawal parameters + */ + static async validateWithdrawal(options: { + provider: IPerpsProvider; + params: WithdrawParams; + }): Promise<{ isValid: boolean; error?: string }> { + const { provider, params } = options; + + try { + return await provider.validateWithdrawal(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('validateWithdrawal', { params }), + ); + throw error; + } + } +} diff --git a/app/components/UI/Perps/controllers/services/DataLakeService.test.ts b/app/components/UI/Perps/controllers/services/DataLakeService.test.ts new file mode 100644 index 00000000000..1d5d49aa022 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/DataLakeService.test.ts @@ -0,0 +1,492 @@ +import { DataLakeService } from './DataLakeService'; +import { + createMockServiceContext, + createMockEvmAccount, +} from '../../__mocks__/serviceMocks'; +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; +import Logger from '../../../../../util/Logger'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { trace, endTrace } from '../../../../../util/trace'; +import { setMeasurement } from '@sentry/react-native'; +import type { ServiceContext } from './ServiceContext'; + +jest.mock('../../utils/accountUtils'); +jest.mock('../../../../../util/Logger'); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger'); +jest.mock('../../../../../util/trace'); +jest.mock('@sentry/react-native'); +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); +jest.mock('react-native-performance', () => ({ + now: jest.fn(() => 1000), +})); + +global.fetch = jest.fn(); +global.setTimeout = jest.fn((fn: () => void) => { + fn(); + return 0 as unknown as NodeJS.Timeout; +}) as unknown as typeof setTimeout; + +describe('DataLakeService', () => { + let mockContext: ServiceContext; + const mockEvmAccount = createMockEvmAccount(); + const mockToken = 'mock-bearer-token'; + + beforeEach(() => { + mockContext = createMockServiceContext({ + errorContext: { controller: 'DataLakeService', method: 'test' }, + messenger: { + call: jest.fn().mockResolvedValue(mockToken), + } as never, + tracingContext: { + provider: 'hyperliquid', + isTestnet: false, + }, + }); + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (trace as jest.Mock).mockReturnValue({ spanId: 'mock-span' }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('reportOrder', () => { + it('skips reporting for testnet', async () => { + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: true, + context: mockContext, + }); + + expect(result).toEqual({ success: true, error: 'Skipped for testnet' }); + expect(DevLogger.log).toHaveBeenCalledWith( + 'DataLake API: Skipping for testnet', + expect.objectContaining({ network: 'testnet' }), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports order successfully on first attempt', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + sl_price: 45000, + tp_price: 55000, + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockContext.messenger?.call).toHaveBeenCalledWith( + 'AuthenticationController:getBearerToken', + ); + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${mockToken}`, + }), + body: JSON.stringify({ + user_id: mockEvmAccount.address, + coin: 'BTC', + sl_price: 45000, + tp_price: 55000, + }), + }), + ); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Data Lake Report', + tags: expect.objectContaining({ action: 'open', coin: 'BTC' }), + }), + ); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: true, retries: 0 }), + }), + ); + }); + + it('includes performance measurement on success', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await DataLakeService.reportOrder({ + action: 'close', + coin: 'ETH', + isTestnet: false, + context: mockContext, + }); + + expect(setMeasurement).toHaveBeenCalledWith( + 'perps.api.data_lake_call', + expect.any(Number), + 'millisecond', + expect.anything(), + ); + }); + + it('returns error when account is missing', async () => { + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + null, + ); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ + success: false, + error: 'No account or token available', + }); + expect(fetch).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'DataLake API: Missing requirements', + expect.objectContaining({ hasAccount: false }), + ); + }); + + it('returns error when token is missing', async () => { + const contextWithoutToken = { + ...mockContext, + messenger: { + call: jest.fn().mockResolvedValue(null), + } as never, + }; + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: contextWithoutToken, + }); + + expect(result).toEqual({ + success: false, + error: 'No account or token available', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns error when messenger is not available', async () => { + const contextWithoutMessenger = createMockServiceContext({ + errorContext: { controller: 'DataLakeService', method: 'test' }, + messenger: undefined, + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: contextWithoutMessenger, + }); + + expect(result).toEqual({ + success: false, + error: 'Messenger not available in ServiceContext', + }); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('retries on network error with exponential backoff', async () => { + (fetch as jest.Mock) + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: false, error: 'Network error' }); + expect(Logger.error).toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'DataLake API: Scheduling retry', + expect.objectContaining({ nextAttempt: 2 }), + ); + expect(setTimeout).toHaveBeenCalled(); + }); + + it('retries up to 3 times then gives up', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Persistent error')); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 3, + }); + + expect(result).toEqual({ + success: false, + error: 'Persistent error', + }); + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + operation: 'finalFailure', + retryCount: 3, + }), + ); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + success: false, + totalRetries: 3, + }), + }), + ); + }); + + it('calculates exponential backoff delays correctly', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 0, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + + jest.clearAllMocks(); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 1, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 2000); + + jest.clearAllMocks(); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 2, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 4000); + }); + + it('handles API error responses', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('Internal Server Error'), + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('DataLake API error: 500'); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('handles API 4xx error responses', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 400, + text: jest.fn().mockResolvedValue('Bad Request'), + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'INVALID', + isTestnet: false, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('DataLake API error: 400'); + }); + + it('logs all retry attempts correctly', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'DataLake API: Starting order report', + expect.objectContaining({ attempt: 1, maxAttempts: 4 }), + ); + }); + + it('uses custom trace ID when provided', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + _traceId: 'custom-trace-id', + }); + + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-trace-id' }), + ); + }); + + it('reports close action with TP/SL prices', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await DataLakeService.reportOrder({ + action: 'close', + coin: 'BTC', + sl_price: 45000, + tp_price: 55000, + isTestnet: false, + context: mockContext, + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + user_id: mockEvmAccount.address, + coin: 'BTC', + sl_price: 45000, + tp_price: 55000, + }), + }), + ); + }); + + it('reports order without TP/SL prices when not provided', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'ETH', + isTestnet: false, + context: mockContext, + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + user_id: mockEvmAccount.address, + coin: 'ETH', + sl_price: undefined, + tp_price: undefined, + }), + }), + ); + }); + + it('handles response with body text', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue('{"orderId": "123"}'), + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(DevLogger.log).toHaveBeenCalledWith( + 'DataLake API: Order reported successfully', + expect.objectContaining({ responseBody: '{"orderId": "123"}' }), + ); + }); + + it('handles empty response body', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(DevLogger.log).toHaveBeenCalledWith( + 'DataLake API: Order reported successfully', + expect.objectContaining({ responseBody: 'empty' }), + ); + }); + + it('only starts trace on first attempt', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await DataLakeService.reportOrder({ + action: 'open', + coin: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 2, + }); + + expect(trace).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/DataLakeService.ts b/app/components/UI/Perps/controllers/services/DataLakeService.ts new file mode 100644 index 00000000000..f3182664f2e --- /dev/null +++ b/app/components/UI/Perps/controllers/services/DataLakeService.ts @@ -0,0 +1,270 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Span } from '@sentry/core'; +import performance from 'react-native-performance'; +import { setMeasurement } from '@sentry/react-native'; +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; +import { + trace, + endTrace, + TraceName, + TraceOperation, +} from '../../../../../util/trace'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { PerpsMeasurementName } from '../../constants/performanceMetrics'; +import { DATA_LAKE_API_CONFIG } from '../../constants/perpsConfig'; +import type { ServiceContext } from './ServiceContext'; + +/** + * DataLakeService + * + * Handles reporting order events to external Data Lake API. + * Implements exponential backoff retry logic and performance tracing. + * Stateless service that operates purely on external API calls. + */ +export class DataLakeService { + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'DataLakeService', + method, + ...additionalContext, + }; + } + + /** + * Report order events to data lake API with retry (non-blocking) + * Implements exponential backoff retry logic (max 3 retries) + * + * @param options - Configuration object + * @param options.action - Order action ('open' or 'close') + * @param options.coin - Market symbol + * @param options.sl_price - Optional stop loss price + * @param options.tp_price - Optional take profit price + * @param options.isTestnet - Whether this is a testnet operation (skips API call) + * @param options.context - ServiceContext for dependencies (messenger, tracing) + * @param options.retryCount - Internal retry counter (managed by service) + * @param options._traceId - Internal trace ID (managed by service) + * @returns Result object with success flag and optional error message + */ + static async reportOrder(options: { + action: 'open' | 'close'; + coin: string; + sl_price?: number; + tp_price?: number; + isTestnet: boolean; + context: ServiceContext; + retryCount?: number; + _traceId?: string; + }): Promise<{ success: boolean; error?: string }> { + const { + action, + coin, + sl_price, + tp_price, + isTestnet, + context, + retryCount = 0, + _traceId, + } = options; + + // Skip data lake reporting for testnet as the API doesn't handle testnet data + if (isTestnet) { + DevLogger.log('DataLake API: Skipping for testnet', { + action, + coin, + network: 'testnet', + }); + return { success: true, error: 'Skipped for testnet' }; + } + + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 1000; + + // Generate trace ID once on first call + const traceId = _traceId || uuidv4(); + + // Start trace only on first attempt + let traceSpan: Span | undefined; + if (retryCount === 0) { + traceSpan = trace({ + name: TraceName.PerpsDataLakeReport, + op: TraceOperation.PerpsOperation, + id: traceId, + tags: { + action, + coin, + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + } + + // Log the attempt + DevLogger.log('DataLake API: Starting order report', { + action, + coin, + attempt: retryCount + 1, + maxAttempts: MAX_RETRIES + 1, + hasStopLoss: !!sl_price, + hasTakeProfit: !!tp_price, + timestamp: new Date().toISOString(), + }); + + const apiCallStartTime = performance.now(); + + try { + // Ensure messenger is available + if (!context.messenger) { + throw new Error('Messenger not available in ServiceContext'); + } + + const token = await context.messenger.call( + 'AuthenticationController:getBearerToken', + ); + const evmAccount = getEvmAccountFromSelectedAccountGroup(); + + if (!evmAccount || !token) { + DevLogger.log('DataLake API: Missing requirements', { + hasAccount: !!evmAccount, + hasToken: !!token, + action, + coin, + }); + return { success: false, error: 'No account or token available' }; + } + + const response = await fetch(DATA_LAKE_API_CONFIG.ORDERS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user_id: evmAccount.address, + coin, + sl_price, + tp_price, + }), + }); + + if (!response.ok) { + throw new Error(`DataLake API error: ${response.status}`); + } + + // Consume response body (might be empty for 201, but good to check) + const responseBody = await response.text(); + + const apiCallDuration = performance.now() - apiCallStartTime; + + // Add measurement to trace if span exists + if (traceSpan) { + setMeasurement( + PerpsMeasurementName.PERPS_DATA_LAKE_API_CALL, + apiCallDuration, + 'millisecond', + traceSpan, + ); + } + + // Success logging + DevLogger.log('DataLake API: Order reported successfully', { + action, + coin, + status: response.status, + attempt: retryCount + 1, + responseBody: responseBody || 'empty', + duration: `${apiCallDuration.toFixed(0)}ms`, + }); + + // End trace on success + endTrace({ + name: TraceName.PerpsDataLakeReport, + id: traceId, + data: { + success: true, + retries: retryCount, + }, + }); + + return { success: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + Logger.error( + ensureError(error), + this.getErrorContext('reportOrder', { + action, + coin, + retryCount, + willRetry: retryCount < MAX_RETRIES, + }), + ); + + // Retry logic + if (retryCount < MAX_RETRIES) { + const retryDelay = RETRY_DELAY_MS * Math.pow(2, retryCount); + DevLogger.log('DataLake API: Scheduling retry', { + retryIn: `${retryDelay}ms`, + nextAttempt: retryCount + 2, + action, + coin, + }); + + setTimeout(() => { + this.reportOrder({ + action, + coin, + sl_price, + tp_price, + isTestnet, + context, + retryCount: retryCount + 1, + _traceId: traceId, + }).catch((err) => { + Logger.error( + ensureError(err), + this.getErrorContext('reportOrder', { + operation: 'retry', + retryCount: retryCount + 1, + action, + coin, + }), + ); + }); + }, retryDelay); + + return { success: false, error: errorMessage }; + } + + endTrace({ + name: TraceName.PerpsDataLakeReport, + id: traceId, + data: { + success: false, + error: errorMessage, + totalRetries: retryCount, + }, + }); + + Logger.error( + ensureError(error), + this.getErrorContext('reportOrder', { + operation: 'finalFailure', + action, + coin, + retryCount, + }), + ); + + return { success: false, error: errorMessage }; + } + } +} diff --git a/app/components/UI/Perps/controllers/services/DepositService.test.ts b/app/components/UI/Perps/controllers/services/DepositService.test.ts new file mode 100644 index 00000000000..95d629f8f8a --- /dev/null +++ b/app/components/UI/Perps/controllers/services/DepositService.test.ts @@ -0,0 +1,293 @@ +import { DepositService } from './DepositService'; +import { createMockHyperLiquidProvider } from '../../__mocks__/providerMocks'; +import { createMockEvmAccount } from '../../__mocks__/serviceMocks'; +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; +import { generateTransferData } from '../../../../../util/transactions'; +import { generateDepositId } from '../../utils/idUtils'; +import { toHex } from '@metamask/controller-utils'; +import { parseCaipAssetId } from '@metamask/utils'; +import type { IPerpsProvider } from '../types'; + +jest.mock('../../utils/accountUtils'); +jest.mock('../../utils/idUtils'); +jest.mock('@metamask/utils'); +jest.mock('../../../../../util/transactions'); +jest.mock('@metamask/controller-utils', () => { + const actual = jest.requireActual('@metamask/controller-utils'); + return { + ...actual, + toHex: jest.fn((value: string | number) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + if (typeof value === 'string' && !value.startsWith('0x')) { + return `0x${parseInt(value, 10).toString(16)}`; + } + return value; + }), + }; +}); + +describe('DepositService', () => { + let mockProvider: jest.Mocked; + const mockEvmAccount = createMockEvmAccount(); + const mockDepositId = 'deposit-123'; + const mockTransferData = '0xabcdef'; + const mockBridgeAddress = '0xBridgeContract'; + const mockTokenAddress = '0xTokenAddress'; + const mockAssetId = 'eip155:42161/erc20:0xTokenAddress/default'; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: mockBridgeAddress, + chainId: 'eip155:42161', + }, + ]); + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (generateDepositId as jest.Mock).mockReturnValue(mockDepositId); + (generateTransferData as jest.Mock).mockReturnValue(mockTransferData); + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:42161', + assetReference: mockTokenAddress, + }); + (toHex as jest.Mock).mockImplementation((value: string | number) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + if (typeof value === 'string' && !value.startsWith('0x')) { + return `0x${parseInt(value, 10).toString(16)}`; + } + return value; + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('prepareTransaction', () => { + it('successfully prepares deposit transaction with all fields', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result).toEqual({ + transaction: { + from: mockEvmAccount.address, + to: mockTokenAddress, + value: '0x0', + data: mockTransferData, + gas: '0x186a0', + }, + assetChainId: '0xa4b1', + currentDepositId: mockDepositId, + }); + }); + + it('generates unique deposit ID for tracking', async () => { + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(generateDepositId).toHaveBeenCalledTimes(1); + }); + + it('retrieves deposit routes from provider', async () => { + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockProvider.getDepositRoutes).toHaveBeenCalledWith({ + isTestnet: false, + }); + }); + + it('uses first deposit route from provider', async () => { + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: mockBridgeAddress, + chainId: 'eip155:42161', + }, + { + assetId: 'eip155:1/erc20:0xOtherToken/default', + contractAddress: '0xOtherBridge', + chainId: 'eip155:1', + }, + ]); + + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(generateTransferData).toHaveBeenCalledWith('transfer', { + toAddress: mockBridgeAddress, + amount: '0x0', + }); + }); + + it('generates transfer data for ERC-20 token transfer', async () => { + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(generateTransferData).toHaveBeenCalledWith('transfer', { + toAddress: mockBridgeAddress, + amount: '0x0', + }); + }); + + it('retrieves EVM account from selected account group', async () => { + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(getEvmAccountFromSelectedAccountGroup).toHaveBeenCalledTimes(1); + }); + + it('throws error when no EVM account is found', async () => { + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + null, + ); + + await expect( + DepositService.prepareTransaction({ + provider: mockProvider, + }), + ).rejects.toThrow( + 'No EVM-compatible account found in selected account group', + ); + + expect(parseCaipAssetId).not.toHaveBeenCalled(); + }); + + it('parses CAIP asset ID to extract chain and token', async () => { + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(parseCaipAssetId).toHaveBeenCalledWith(mockAssetId); + }); + + it('converts chain ID to hex format', async () => { + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(toHex).toHaveBeenCalledWith('42161'); + }); + + it('sets fixed gas limit for deposit transaction', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.gas).toBe('0x186a0'); + }); + + it('sets transaction value to 0x0', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.value).toBe('0x0'); + }); + + it('uses token address as transaction recipient', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.to).toBe(mockTokenAddress); + }); + + it('uses account address as transaction sender', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.from).toBe(mockEvmAccount.address); + }); + + it('includes generated transfer data in transaction', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.data).toBe(mockTransferData); + }); + + it('returns asset chain ID in hex format', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.assetChainId).toBe('0xa4b1'); + }); + + it('returns current deposit ID for tracking', async () => { + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.currentDepositId).toBe(mockDepositId); + }); + + it('handles different chain IDs correctly', async () => { + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:1', + assetReference: mockTokenAddress, + }); + + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(toHex).toHaveBeenCalledWith('1'); + }); + + it('handles different token addresses correctly', async () => { + const differentTokenAddress = '0xDifferentToken'; + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:42161', + assetReference: differentTokenAddress, + }); + + const result = await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.to).toBe(differentTokenAddress); + }); + + it('prepares transaction for different bridge contracts', async () => { + const differentBridgeAddress = '0xDifferentBridge'; + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: differentBridgeAddress, + chainId: 'eip155:42161', + }, + ]); + + await DepositService.prepareTransaction({ + provider: mockProvider, + }); + + expect(generateTransferData).toHaveBeenCalledWith('transfer', { + toAddress: differentBridgeAddress, + amount: '0x0', + }); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/DepositService.ts b/app/components/UI/Perps/controllers/services/DepositService.ts new file mode 100644 index 00000000000..6654e926bc4 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/DepositService.ts @@ -0,0 +1,80 @@ +import { toHex } from '@metamask/controller-utils'; +import { parseCaipAssetId, type Hex } from '@metamask/utils'; +import type { TransactionParams } from '@metamask/transaction-controller'; +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; +import { generateTransferData } from '../../../../../util/transactions'; +import { generateDepositId } from '../../utils/idUtils'; +import type { IPerpsProvider } from '../types'; + +// Temporary to avoid estimation failures due to insufficient balance +const DEPOSIT_GAS_LIMIT = toHex(100000); + +/** + * DepositService + * + * Handles deposit transaction preparation and validation. + * Stateless service that prepares transaction data for TransactionController. + * Controller handles TransactionController integration and promise lifecycle. + */ +export class DepositService { + /** + * Prepare deposit transaction for confirmation + * Extracts transaction construction logic from controller + * + * @param options - Configuration object + * @param options.provider - Active provider instance + * @returns Transaction data ready for TransactionController.addTransaction + */ + static async prepareTransaction(options: { + provider: IPerpsProvider; + }): Promise<{ + transaction: TransactionParams; + assetChainId: Hex; + currentDepositId: string; + }> { + const { provider } = options; + + // Generate deposit request ID for tracking + const currentDepositId = generateDepositId(); + + // Get deposit routes from provider + const depositRoutes = provider.getDepositRoutes({ isTestnet: false }); + const route = depositRoutes[0]; + const bridgeContractAddress = route.contractAddress; + + // Generate transfer data for ERC-20 token transfer + const transferData = generateTransferData('transfer', { + toAddress: bridgeContractAddress, + amount: '0x0', + }); + + // Get EVM account from selected account group + const evmAccount = getEvmAccountFromSelectedAccountGroup(); + if (!evmAccount) { + throw new Error( + 'No EVM-compatible account found in selected account group', + ); + } + const accountAddress = evmAccount.address as Hex; + + // Parse CAIP asset ID to extract chain ID and token address + const parsedAsset = parseCaipAssetId(route.assetId); + const assetChainId = toHex(parsedAsset.chainId.split(':')[1]) as Hex; + const tokenAddress = parsedAsset.assetReference as Hex; + + // Build transaction parameters for TransactionController + const transaction: TransactionParams = { + from: accountAddress, + to: tokenAddress, + value: '0x0', + data: transferData, + gas: DEPOSIT_GAS_LIMIT, + }; + + return { + transaction, + assetChainId, + currentDepositId, + }; + } +} diff --git a/app/components/UI/Perps/controllers/services/EligibilityService.test.ts b/app/components/UI/Perps/controllers/services/EligibilityService.test.ts new file mode 100644 index 00000000000..820c30753a2 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/EligibilityService.test.ts @@ -0,0 +1,399 @@ +import { EligibilityService } from './EligibilityService'; +import { successfulFetch } from '@metamask/controller-utils'; +import { getEnvironment } from '../utils'; +import Logger from '../../../../../util/Logger'; + +jest.mock('@metamask/controller-utils'); +jest.mock('../utils'); +jest.mock('../../../../../util/Logger'); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger'); + +describe('EligibilityService', () => { + beforeEach(() => { + jest.clearAllMocks(); + EligibilityService.clearCache(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + }); + + describe('fetchGeoLocation', () => { + it('fetches geo-location from API on first call', async () => { + const mockLocation = 'US'; + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => mockLocation, + }); + + const result = await EligibilityService.fetchGeoLocation(); + + expect(result).toBe('US'); + expect(successfulFetch).toHaveBeenCalledWith( + 'https://on-ramp.api.cx.metamask.io/geolocation', + ); + }); + + it('returns cached geo-location within TTL (5 minutes)', async () => { + const mockLocation = 'UK'; + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => mockLocation, + }); + + const firstResult = await EligibilityService.fetchGeoLocation(); + + jest.advanceTimersByTime(4 * 60 * 1000); // 4 minutes + + const secondResult = await EligibilityService.fetchGeoLocation(); + + expect(firstResult).toBe('UK'); + expect(secondResult).toBe('UK'); + expect(successfulFetch).toHaveBeenCalledTimes(1); + }); + + it('refetches geo-location after cache expiry (5 minutes)', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock) + .mockResolvedValueOnce({ text: async () => 'US' }) + .mockResolvedValueOnce({ text: async () => 'CA' }); + + const firstResult = await EligibilityService.fetchGeoLocation(); + + jest.advanceTimersByTime(6 * 60 * 1000); // 6 minutes - cache expired + + const secondResult = await EligibilityService.fetchGeoLocation(); + + expect(firstResult).toBe('US'); + expect(secondResult).toBe('CA'); + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + + it('deduplicates concurrent requests', async () => { + const mockLocation = 'FR'; + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + + let resolvePromise!: (value: { text: () => Promise }) => void; + const fetchPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + (successfulFetch as jest.Mock).mockReturnValue(fetchPromise); + + const promise1 = EligibilityService.fetchGeoLocation(); + const promise2 = EligibilityService.fetchGeoLocation(); + const promise3 = EligibilityService.fetchGeoLocation(); + + resolvePromise({ text: async () => mockLocation }); + + const [result1, result2, result3] = await Promise.all([ + promise1, + promise2, + promise3, + ]); + + expect(result1).toBe('FR'); + expect(result2).toBe('FR'); + expect(result3).toBe('FR'); + expect(successfulFetch).toHaveBeenCalledTimes(1); + }); + + it('uses DEV endpoint when environment is DEV', async () => { + (getEnvironment as jest.Mock).mockReturnValue('DEV'); + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'US', + }); + + await EligibilityService.fetchGeoLocation(); + + expect(successfulFetch).toHaveBeenCalledWith( + 'https://on-ramp.uat-api.cx.metamask.io/geolocation', + ); + }); + + it('uses PROD endpoint when environment is PROD', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'US', + }); + + await EligibilityService.fetchGeoLocation(); + + expect(successfulFetch).toHaveBeenCalledWith( + 'https://on-ramp.api.cx.metamask.io/geolocation', + ); + }); + + it('returns UNKNOWN when API fails', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockRejectedValue( + new Error('Network error'), + ); + + const result = await EligibilityService.fetchGeoLocation(); + + expect(result).toBe('UNKNOWN'); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('returns UNKNOWN when API returns empty response', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockResolvedValue({}); + + const result = await EligibilityService.fetchGeoLocation(); + + expect(result).toBe('UNKNOWN'); + }); + + it('returns UNKNOWN when API returns null', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockResolvedValue(null); + + const result = await EligibilityService.fetchGeoLocation(); + + expect(result).toBe('UNKNOWN'); + }); + + it('logs error with proper context when fetch fails', async () => { + const mockError = new Error('API timeout'); + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockRejectedValue(mockError); + + await EligibilityService.fetchGeoLocation(); + + expect(Logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + controller: 'EligibilityService', + method: 'performGeoLocationFetch', + }), + ); + }); + }); + + describe('checkEligibility', () => { + beforeEach(() => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + }); + + it('returns true when user is not in blocked regions', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'FR', + }); + + const result = await EligibilityService.checkEligibility(['US', 'CN']); + + expect(result).toBe(true); + }); + + it('returns false when user is in blocked region', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'US', + }); + + const result = await EligibilityService.checkEligibility(['US', 'CN']); + + expect(result).toBe(false); + }); + + it('returns false when user is in any blocked region from list', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'CN', + }); + + const result = await EligibilityService.checkEligibility([ + 'US', + 'CN', + 'KP', + 'IR', + ]); + + expect(result).toBe(false); + }); + + it('returns true when blocked regions list is empty', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'US', + }); + + const result = await EligibilityService.checkEligibility([]); + + expect(result).toBe(true); + }); + + it('returns true when location is UNKNOWN (defaults to eligible)', async () => { + (successfulFetch as jest.Mock).mockRejectedValue(new Error('API error')); + + const result = await EligibilityService.checkEligibility(['US', 'CN']); + + expect(result).toBe(true); + }); + + it('handles partial region codes (e.g., US-NY)', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'US-NY', + }); + + const resultWithUS = await EligibilityService.checkEligibility(['US']); + + expect(resultWithUS).toBe(false); + }); + + it('performs case-insensitive region matching', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'us', + }); + + const result = await EligibilityService.checkEligibility(['US']); + + expect(result).toBe(false); + }); + + it('caches eligibility check results', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'FR', + }); + + const result1 = await EligibilityService.checkEligibility(['US']); + const result2 = await EligibilityService.checkEligibility(['US']); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(successfulFetch).toHaveBeenCalledTimes(1); + }); + + it('returns true when fetch throws error (fail-safe)', async () => { + (successfulFetch as jest.Mock).mockRejectedValue( + new Error('Network failure'), + ); + + const result = await EligibilityService.checkEligibility(['US', 'CN']); + + expect(result).toBe(true); + }); + + it('handles multiple concurrent eligibility checks', async () => { + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'FR', + }); + + const [result1, result2, result3] = await Promise.all([ + EligibilityService.checkEligibility(['US']), + EligibilityService.checkEligibility(['CN']), + EligibilityService.checkEligibility(['UK']), + ]); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(result3).toBe(true); + expect(successfulFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('clearCache', () => { + it('clears cached geo-location', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock) + .mockResolvedValueOnce({ text: async () => 'US' }) + .mockResolvedValueOnce({ text: async () => 'CA' }); + + const firstResult = await EligibilityService.fetchGeoLocation(); + + EligibilityService.clearCache(); + + const secondResult = await EligibilityService.fetchGeoLocation(); + + expect(firstResult).toBe('US'); + expect(secondResult).toBe('CA'); + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + + it('clears in-flight fetch promise', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + + let resolveFirst!: (value: { text: () => Promise }) => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + (successfulFetch as jest.Mock) + .mockReturnValueOnce(firstPromise) + .mockResolvedValueOnce({ text: async () => 'CA' }); + + const fetchPromise = EligibilityService.fetchGeoLocation(); + + EligibilityService.clearCache(); + + const newFetchResult = await EligibilityService.fetchGeoLocation(); + + resolveFirst({ text: async () => 'US' }); + await fetchPromise; + + expect(newFetchResult).toBe('CA'); + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + + it('allows new cache to be built after clearing', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock).mockResolvedValue({ + text: async () => 'UK', + }); + + await EligibilityService.fetchGeoLocation(); + + EligibilityService.clearCache(); + + const result = await EligibilityService.fetchGeoLocation(); + + jest.advanceTimersByTime(4 * 60 * 1000); + + const cachedResult = await EligibilityService.fetchGeoLocation(); + + expect(result).toBe('UK'); + expect(cachedResult).toBe('UK'); + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('cache TTL behavior', () => { + it('respects 5-minute cache TTL exactly', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock) + .mockResolvedValueOnce({ text: async () => 'US' }) + .mockResolvedValueOnce({ text: async () => 'CA' }); + + await EligibilityService.fetchGeoLocation(); + + jest.advanceTimersByTime(5 * 60 * 1000 - 1); // 1ms before expiry + + await EligibilityService.fetchGeoLocation(); + + expect(successfulFetch).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(2); // 1ms after expiry + + await EligibilityService.fetchGeoLocation(); + + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + + it('cache expires after 5 minutes from initial fetch', async () => { + (getEnvironment as jest.Mock).mockReturnValue('PROD'); + (successfulFetch as jest.Mock) + .mockResolvedValueOnce({ text: async () => 'US' }) + .mockResolvedValueOnce({ text: async () => 'CA' }); + + await EligibilityService.fetchGeoLocation(); + + jest.advanceTimersByTime(3 * 60 * 1000); // 3 minutes + + await EligibilityService.fetchGeoLocation(); // Still within cache TTL + + jest.advanceTimersByTime(3 * 60 * 1000); // Another 3 minutes (6 total from first fetch) + + await EligibilityService.fetchGeoLocation(); // Cache expired, new fetch + + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/EligibilityService.ts b/app/components/UI/Perps/controllers/services/EligibilityService.ts new file mode 100644 index 00000000000..85fe070ba61 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/EligibilityService.ts @@ -0,0 +1,177 @@ +import { successfulFetch } from '@metamask/controller-utils'; +import { getEnvironment } from '../utils'; +import Logger from '../../../../../util/Logger'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { ensureError } from '../../utils/perpsErrorHandler'; + +// Geo-blocking API URLs +const ON_RAMP_GEO_BLOCKING_URLS = { + DEV: 'https://on-ramp.uat-api.cx.metamask.io/geolocation', + PROD: 'https://on-ramp.api.cx.metamask.io/geolocation', +} as const; + +/** + * Geo-location cache entry + */ +interface GeoLocationCache { + location: string; + timestamp: number; +} + +/** + * EligibilityService + * + * Handles geo-location fetching and eligibility checking. + * Manages caching to minimize API calls. + */ +export class EligibilityService { + private static geoLocationCache: GeoLocationCache | null = null; + private static geoLocationFetchPromise: Promise | null = null; + private static readonly GEO_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'EligibilityService', + method, + ...additionalContext, + }; + } + + /** + * Fetch geo location with caching and deduplication + */ + static async fetchGeoLocation(): Promise { + // Check cache first + if (this.geoLocationCache) { + const cacheAge = Date.now() - this.geoLocationCache.timestamp; + if (cacheAge < this.GEO_CACHE_TTL_MS) { + DevLogger.log('EligibilityService: Using cached geo location', { + location: this.geoLocationCache.location, + cacheAge: `${(cacheAge / 1000).toFixed(1)}s`, + }); + return this.geoLocationCache.location; + } + } + + // If already fetching, return the existing promise + if (this.geoLocationFetchPromise) { + DevLogger.log( + 'EligibilityService: Geo location fetch already in progress, waiting...', + ); + return this.geoLocationFetchPromise; + } + + // Start new fetch + this.geoLocationFetchPromise = this.performGeoLocationFetch(); + + try { + const location = await this.geoLocationFetchPromise; + return location; + } finally { + // Clear the promise after completion (success or failure) + this.geoLocationFetchPromise = null; + } + } + + /** + * Perform the actual geo location fetch + * Separated to allow proper promise management + */ + private static async performGeoLocationFetch(): Promise { + let location = 'UNKNOWN'; + + try { + const environment = getEnvironment(); + + DevLogger.log('EligibilityService: Fetching geo location from API', { + environment, + }); + + const response = await successfulFetch( + ON_RAMP_GEO_BLOCKING_URLS[environment], + ); + + const textResult = await response?.text(); + location = textResult || 'UNKNOWN'; + + // Cache the successful result + this.geoLocationCache = { + location, + timestamp: Date.now(), + }; + + DevLogger.log('EligibilityService: Geo location fetched successfully', { + location, + }); + + return location; + } catch (e) { + Logger.error( + ensureError(e), + this.getErrorContext('performGeoLocationFetch'), + ); + // Don't cache failures + return location; + } + } + + /** + * Check if user is eligible based on geo-blocked regions + * @param blockedRegions - List of blocked region codes (e.g., ['US', 'CN']) + * @returns true if eligible (not in blocked region), false otherwise + */ + static async checkEligibility(blockedRegions: string[]): Promise { + try { + DevLogger.log('EligibilityService: Checking eligibility', { + blockedRegionsCount: blockedRegions.length, + }); + + // Returns UNKNOWN if we can't fetch the geo location + const geoLocation = await this.fetchGeoLocation(); + + // Only set to eligible if we have valid geolocation and it's not blocked + if (geoLocation !== 'UNKNOWN') { + const isEligible = blockedRegions.every( + (geoBlockedRegion) => + !geoLocation + .toUpperCase() + .startsWith(geoBlockedRegion.toUpperCase()), + ); + + DevLogger.log('EligibilityService: Eligibility check completed', { + geoLocation, + isEligible, + blockedRegions, + }); + + return isEligible; + } + + // Default to eligible if location is unknown + return true; + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('checkEligibility'), + ); + // Default to eligible on error + return true; + } + } + + /** + * Clear the geo-location cache + * Useful for testing or forcing a fresh fetch + */ + static clearCache(): void { + this.geoLocationCache = null; + this.geoLocationFetchPromise = null; + DevLogger.log('EligibilityService: Cache cleared'); + } +} diff --git a/app/components/UI/Perps/controllers/services/FeatureFlagConfigurationService.test.ts b/app/components/UI/Perps/controllers/services/FeatureFlagConfigurationService.test.ts new file mode 100644 index 00000000000..71b2269e249 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/FeatureFlagConfigurationService.test.ts @@ -0,0 +1,549 @@ +import { FeatureFlagConfigurationService } from './FeatureFlagConfigurationService'; +import { createMockServiceContext } from '../../__mocks__/serviceMocks'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../../util/Logger'; +import { validatedVersionGatedFeatureFlag } from '../../../../../util/remoteFeatureFlag'; +import { parseCommaSeparatedString } from '../../utils/stringParseUtils'; +import type { ServiceContext } from './ServiceContext'; +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +jest.mock('../../../../../core/SDKConnect/utils/DevLogger'); +jest.mock('../../../../../util/Logger'); +jest.mock('../../../../../util/remoteFeatureFlag'); +jest.mock('../../utils/stringParseUtils'); + +describe('FeatureFlagConfigurationService', () => { + let mockContext: ServiceContext; + let mockRemoteFeatureFlagState: RemoteFeatureFlagControllerState; + let mockCurrentHip3Config: { + enabled: boolean; + allowlistMarkets: string[]; + blocklistMarkets: string[]; + source: 'remote' | 'fallback'; + }; + let mockCurrentBlockedRegionList: { + list: string[]; + source: 'remote' | 'fallback'; + }; + + beforeEach(() => { + mockCurrentHip3Config = { + enabled: false, + allowlistMarkets: [], + blocklistMarkets: [], + source: 'fallback', + }; + + mockCurrentBlockedRegionList = { + list: [], + source: 'fallback', + }; + + mockContext = createMockServiceContext({ + errorContext: { + controller: 'FeatureFlagConfigurationService', + method: 'test', + }, + getHip3Config: jest.fn(() => mockCurrentHip3Config), + setHip3Config: jest.fn((config) => { + Object.assign(mockCurrentHip3Config, config); + }), + incrementHip3ConfigVersion: jest.fn(() => 1), + getBlockedRegionList: jest.fn(() => mockCurrentBlockedRegionList), + setBlockedRegionList: jest.fn((list, source) => { + mockCurrentBlockedRegionList = { list, source }; + }), + refreshEligibility: jest.fn().mockResolvedValue(undefined), + }); + + mockRemoteFeatureFlagState = { + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }; + + (parseCommaSeparatedString as jest.Mock).mockImplementation((str: string) => + str + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('refreshHip3Config', () => { + it('throws error when required callbacks are missing', () => { + const contextWithoutCallbacks = createMockServiceContext({ + getHip3Config: undefined, + }); + + expect(() => { + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: contextWithoutCallbacks, + }); + }).toThrow('Required HIP-3 callbacks not available in ServiceContext'); + }); + + it('updates config when equity flag changes', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue(true); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true }, + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + source: 'remote', + }), + ); + }); + + it('increments version when equity flag changes', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue(true); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true }, + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.incrementHip3ConfigVersion).toHaveBeenCalledTimes(1); + }); + + it('parses allowlist markets from comma-separated string', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 'BTC,ETH,SOL', + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(parseCommaSeparatedString).toHaveBeenCalledWith('BTC,ETH,SOL'); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('parses allowlist markets from array', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['BTC', 'ETH', 'SOL'], + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('trims and filters empty allowlist markets from array', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['BTC ', ' ETH', ' ', 'SOL'], + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('skips invalid allowlist markets format', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 123, + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + expect.stringContaining('validation FAILED'), + expect.anything(), + ); + }); + + it('parses blocklist markets from comma-separated string', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: 'MEME,DOGE', + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(parseCommaSeparatedString).toHaveBeenCalledWith('MEME,DOGE'); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['MEME', 'DOGE'], + }), + ); + }); + + it('parses blocklist markets from array', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: ['MEME', 'DOGE'], + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['MEME', 'DOGE'], + }), + ); + }); + + it('detects no change when config is identical', () => { + mockCurrentHip3Config.enabled = true; + mockCurrentHip3Config.allowlistMarkets = ['BTC', 'ETH']; + mockCurrentHip3Config.blocklistMarkets = ['MEME']; + + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue(true); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true }, + perpsHip3AllowlistMarkets: ['BTC', 'ETH'], + perpsHip3BlocklistMarkets: ['MEME'], + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + expect(mockContext.incrementHip3ConfigVersion).not.toHaveBeenCalled(); + }); + + it('detects change even when markets are in different order', () => { + mockCurrentHip3Config.allowlistMarkets = ['BTC', 'ETH']; + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['ETH', 'SOL'], + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalled(); + }); + + it('logs config change details', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue(true); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true }, + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + expect.stringContaining('HIP-3 config changed'), + expect.objectContaining({ + equityChanged: true, + oldEquity: false, + newEquity: true, + }), + ); + }); + + it('logs version increment', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue(true); + (mockContext.incrementHip3ConfigVersion as jest.Mock).mockReturnValue(42); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true }, + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Incremented hip3ConfigVersion'), + expect.objectContaining({ newVersion: 42 }), + ); + }); + + it('handles empty string for allowlist markets', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + (parseCommaSeparatedString as jest.Mock).mockReturnValue([]); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: '', + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + expect.stringContaining('allowlistMarkets string was empty'), + expect.anything(), + ); + }); + + it('handles empty string for blocklist markets', () => { + (validatedVersionGatedFeatureFlag as jest.Mock).mockReturnValue( + undefined, + ); + (parseCommaSeparatedString as jest.Mock).mockReturnValue([]); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: '', + }; + + FeatureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + expect.stringContaining('blocklistMarkets string was empty'), + expect.anything(), + ); + }); + }); + + describe('refreshEligibility', () => { + it('extracts blocked regions from remote feature flag', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US', 'CA', 'UK'], + }, + }; + + FeatureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA', 'UK'], + 'remote', + ); + }); + + it('calls refreshHip3Config', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }; + + const refreshHip3ConfigSpy = jest.spyOn( + FeatureFlagConfigurationService, + 'refreshHip3Config', + ); + + FeatureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(refreshHip3ConfigSpy).toHaveBeenCalledWith({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + refreshHip3ConfigSpy.mockRestore(); + }); + + it('skips setting blocked regions when not an array', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: 'invalid', + }, + }; + + FeatureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).not.toHaveBeenCalled(); + }); + + it('handles missing blocked regions gracefully', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: {}, + }; + + expect(() => { + FeatureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + }).not.toThrow(); + }); + }); + + describe('setBlockedRegions', () => { + it('throws error when required callbacks are missing', () => { + const contextWithoutCallbacks = createMockServiceContext({ + getBlockedRegionList: undefined, + }); + + expect(() => { + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: contextWithoutCallbacks, + }); + }).toThrow( + 'Required blocked region callbacks not available in ServiceContext', + ); + }); + + it('sets blocked region list', () => { + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US', 'CA', 'UK'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA', 'UK'], + 'remote', + ); + }); + + it('triggers eligibility refresh after setting list', () => { + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.refreshEligibility).toHaveBeenCalledTimes(1); + }); + + it('implements sticky remote pattern - does not downgrade from remote to fallback', () => { + mockCurrentBlockedRegionList.source = 'remote'; + + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'fallback', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).not.toHaveBeenCalled(); + expect(mockContext.refreshEligibility).not.toHaveBeenCalled(); + }); + + it('allows upgrade from fallback to remote', () => { + mockCurrentBlockedRegionList.source = 'fallback'; + + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US', 'CA'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA'], + 'remote', + ); + }); + + it('handles eligibility refresh error gracefully', () => { + (mockContext.refreshEligibility as jest.Mock).mockRejectedValue( + new Error('Refresh failed'), + ); + + expect(() => { + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + }).not.toThrow(); + }); + + it('logs error when eligibility refresh fails', async () => { + (mockContext.refreshEligibility as jest.Mock).mockRejectedValue( + new Error('Refresh failed'), + ); + + FeatureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it('handles empty blocked region list', () => { + FeatureFlagConfigurationService.setBlockedRegions({ + list: [], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + [], + 'remote', + ); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/FeatureFlagConfigurationService.ts b/app/components/UI/Perps/controllers/services/FeatureFlagConfigurationService.ts new file mode 100644 index 00000000000..36363ce4b6c --- /dev/null +++ b/app/components/UI/Perps/controllers/services/FeatureFlagConfigurationService.ts @@ -0,0 +1,330 @@ +import { hasProperty } from '@metamask/utils'; +import { + type VersionGatedFeatureFlag, + validatedVersionGatedFeatureFlag, +} from '../../../../../util/remoteFeatureFlag'; +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import { parseCommaSeparatedString } from '../../utils/stringParseUtils'; +import type { ServiceContext } from './ServiceContext'; + +/** + * FeatureFlagConfigurationService + * + * Handles HIP-3 configuration and geo-blocking configuration from remote feature flags. + * Implements "sticky remote" pattern: once remote config is loaded, never downgrade to fallback. + * Orchestrates validation, change detection, and version management for feature flag updates. + * + * Responsibilities: + * - Remote feature flag validation and parsing + * - HIP-3 configuration management (equity, allowlist, blocklist) + * - Geo-blocking configuration from remote flags + * - Change detection and version management + * - "Sticky remote" pattern enforcement (never downgrade) + */ +export class FeatureFlagConfigurationService { + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'FeatureFlagConfigurationService', + method, + ...additionalContext, + }; + } + + /** + * Validate and parse market list from remote feature flags + * Handles both string (comma-separated) and array formats from LaunchDarkly + */ + private static validateMarketList( + remoteValue: unknown, + fieldName: string, + currentValue: string[], + ): string[] | undefined { + DevLogger.log(`PerpsController: HIP-3 ${fieldName} validation`, { + remoteValue, + type: typeof remoteValue, + isArray: Array.isArray(remoteValue), + }); + + // LaunchDarkly returns comma-separated strings for list values + if (typeof remoteValue === 'string') { + const parsed = parseCommaSeparatedString(remoteValue); + + if (parsed.length > 0) { + DevLogger.log( + `PerpsController: HIP-3 ${fieldName} validated from string`, + { validatedMarkets: parsed }, + ); + return parsed; + } + + DevLogger.log( + `PerpsController: HIP-3 ${fieldName} string was empty after parsing`, + { fallbackValue: currentValue }, + ); + return undefined; + } + + // Fallback: Validate array of non-empty strings + if ( + Array.isArray(remoteValue) && + remoteValue.every((item) => typeof item === 'string' && item.length > 0) + ) { + const validatedMarkets = (remoteValue as string[]) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + DevLogger.log( + `PerpsController: HIP-3 ${fieldName} validated from array`, + { validatedMarkets }, + ); + return validatedMarkets; + } + + DevLogger.log( + `PerpsController: HIP-3 ${fieldName} validation FAILED - falling back to local config`, + { + reason: Array.isArray(remoteValue) + ? 'Array contains non-string or empty values' + : 'Invalid type (expected string or array)', + fallbackValue: currentValue, + }, + ); + return undefined; + } + + /** + * Check if arrays have different values (order-independent comparison) + */ + private static arraysHaveDifferentValues(a: string[], b: string[]): boolean { + return ( + JSON.stringify([...a].sort((x, y) => x.localeCompare(y))) !== + JSON.stringify([...b].sort((x, y) => x.localeCompare(y))) + ); + } + + /** + * Refresh HIP-3 configuration when remote feature flags change. + * This method extracts HIP-3 settings from remote flags, validates them, + * and updates internal state if they differ from current values. + * When config changes, increments hip3ConfigVersion to trigger ConnectionManager reconnection. + * + * Follows the "sticky remote" pattern: once remote config is loaded, never downgrade to fallback. + * + * @param options - Configuration object + * @param options.remoteFeatureFlagControllerState - State from RemoteFeatureFlagController + * @param options.context - ServiceContext providing state access callbacks + */ + static refreshHip3Config(options: { + remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState; + context: ServiceContext; + }): void { + const { remoteFeatureFlagControllerState, context } = options; + + if ( + !context.getHip3Config || + !context.setHip3Config || + !context.incrementHip3ConfigVersion + ) { + throw new Error( + 'Required HIP-3 callbacks not available in ServiceContext', + ); + } + + const remoteFlags = remoteFeatureFlagControllerState.remoteFeatureFlags; + const currentConfig = context.getHip3Config(); + + // Extract and validate remote HIP-3 equity enabled flag + const equityFlag = + remoteFlags?.perpsHip3Enabled as unknown as VersionGatedFeatureFlag; + const validatedEquity = validatedVersionGatedFeatureFlag(equityFlag); + + DevLogger.log('PerpsController: HIP-3 equity flag validation', { + equityFlag, + validatedEquity, + willUse: validatedEquity !== undefined ? 'remote' : 'fallback', + }); + + // Extract and validate remote HIP-3 market lists + const validatedAllowlistMarkets = hasProperty( + remoteFlags, + 'perpsHip3AllowlistMarkets', + ) + ? this.validateMarketList( + remoteFlags.perpsHip3AllowlistMarkets, + 'allowlistMarkets', + currentConfig.allowlistMarkets, + ) + : undefined; + + const validatedBlocklistMarkets = hasProperty( + remoteFlags, + 'perpsHip3BlocklistMarkets', + ) + ? this.validateMarketList( + remoteFlags.perpsHip3BlocklistMarkets, + 'blocklistMarkets', + currentConfig.blocklistMarkets, + ) + : undefined; + + // Detect changes (only if we have valid remote values) + const equityChanged = + validatedEquity !== undefined && + validatedEquity !== currentConfig.enabled; + const allowlistMarketsChanged = + validatedAllowlistMarkets !== undefined && + this.arraysHaveDifferentValues( + validatedAllowlistMarkets, + currentConfig.allowlistMarkets, + ); + const blocklistMarketsChanged = + validatedBlocklistMarkets !== undefined && + this.arraysHaveDifferentValues( + validatedBlocklistMarkets, + currentConfig.blocklistMarkets, + ); + + if (equityChanged || allowlistMarketsChanged || blocklistMarketsChanged) { + DevLogger.log( + 'PerpsController: HIP-3 config changed via remote feature flags', + { + equityChanged, + allowlistMarketsChanged, + blocklistMarketsChanged, + oldEquity: currentConfig.enabled, + newEquity: validatedEquity, + oldAllowlistMarkets: currentConfig.allowlistMarkets, + newAllowlistMarkets: validatedAllowlistMarkets, + oldBlocklistMarkets: currentConfig.blocklistMarkets, + newBlocklistMarkets: validatedBlocklistMarkets, + source: 'remote', + }, + ); + + // Update internal state (sticky remote - never downgrade) + context.setHip3Config({ + enabled: validatedEquity, + allowlistMarkets: validatedAllowlistMarkets + ? [...validatedAllowlistMarkets] + : undefined, + blocklistMarkets: validatedBlocklistMarkets + ? [...validatedBlocklistMarkets] + : undefined, + source: 'remote', + }); + + // Increment version to trigger ConnectionManager reconnection and cache clearing + const newVersion = context.incrementHip3ConfigVersion(); + + DevLogger.log( + 'PerpsController: Incremented hip3ConfigVersion to trigger reconnection', + { + newVersion, + newHip3Enabled: validatedEquity ?? currentConfig.enabled, + newHip3AllowlistMarkets: + validatedAllowlistMarkets ?? currentConfig.allowlistMarkets, + newHip3BlocklistMarkets: + validatedBlocklistMarkets ?? currentConfig.blocklistMarkets, + }, + ); + + // Note: ConnectionManager will handle: + // 1. Detecting hip3ConfigVersion change via Redux monitoring + // 2. Clearing all StreamManager caches + // 3. Calling reconnectWithNewContext() -> initializeProviders() + // 4. Provider reinitialization will read the new HIP-3 config below + } + } + + /** + * Respond to RemoteFeatureFlagController state changes + * Refreshes user eligibility based on geo-blocked regions defined in remote feature flag. + * Uses fallback configuration when remote feature flag is undefined. + * Note: Initial eligibility is set in the constructor if fallback regions are provided. + * + * @param options - Configuration object + * @param options.remoteFeatureFlagControllerState - State from RemoteFeatureFlagController + * @param options.context - ServiceContext providing callbacks + */ + static refreshEligibility(options: { + remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState; + context: ServiceContext; + }): void { + const { remoteFeatureFlagControllerState, context } = options; + + const perpsGeoBlockedRegionsFeatureFlag = + // NOTE: Do not use perpsPerpTradingGeoBlockedCountries as it is deprecated. + remoteFeatureFlagControllerState.remoteFeatureFlags + ?.perpsPerpTradingGeoBlockedCountriesV2; + + const remoteBlockedRegions = ( + perpsGeoBlockedRegionsFeatureFlag as { blockedRegions?: string[] } + )?.blockedRegions; + + if (Array.isArray(remoteBlockedRegions)) { + this.setBlockedRegions({ + list: remoteBlockedRegions, + source: 'remote', + context, + }); + } + + // Also check for HIP-3 config changes + this.refreshHip3Config({ remoteFeatureFlagControllerState, context }); + } + + /** + * Set blocked region list with "never downgrade" pattern enforcement + * Updates the blocked region list and triggers eligibility refresh. + * Implements "sticky remote": once remote regions are set, never downgrade to fallback. + * + * @param options - Configuration object + * @param options.list - Array of blocked region codes + * @param options.source - Source of the list ('remote' or 'fallback') + * @param options.context - ServiceContext providing callbacks + */ + static setBlockedRegions(options: { + list: string[]; + source: 'remote' | 'fallback'; + context: ServiceContext; + }): void { + const { list, source, context } = options; + + if ( + !context.getBlockedRegionList || + !context.setBlockedRegionList || + !context.refreshEligibility + ) { + throw new Error( + 'Required blocked region callbacks not available in ServiceContext', + ); + } + + const currentList = context.getBlockedRegionList(); + + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + + context.refreshEligibility().catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('setBlockedRegions', { source }), + ); + }); + } +} diff --git a/app/components/UI/Perps/controllers/services/MarketDataService.test.ts b/app/components/UI/Perps/controllers/services/MarketDataService.test.ts new file mode 100644 index 00000000000..572b0a4271d --- /dev/null +++ b/app/components/UI/Perps/controllers/services/MarketDataService.test.ts @@ -0,0 +1,939 @@ +import { MarketDataService } from './MarketDataService'; +import { createMockServiceContext } from '../../__mocks__/serviceMocks'; +import { + createMockHyperLiquidProvider, + createMockPosition, + createMockOrder, +} from '../../__mocks__/providerMocks'; +import { trace, endTrace } from '../../../../../util/trace'; +import Logger from '../../../../../util/Logger'; +import { setMeasurement } from '@sentry/react-native'; +import type { ServiceContext } from './ServiceContext'; +import type { + IPerpsProvider, + Position, + AccountState, + Order, + OrderFill, + Funding, + MarketInfo, + FeeCalculationResult, + FeeCalculationParams, + AssetRoute, +} from '../types'; +import type { CandleData } from '../../types/perps-types'; +import type { CandlePeriod } from '../../constants/chartConfig'; + +jest.mock('../../../../../util/trace'); +jest.mock('../../../../../util/Logger'); +jest.mock('@sentry/react-native'); +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); +jest.mock('react-native-performance', () => ({ + now: jest.fn(() => 1000), +})); + +describe('MarketDataService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockContext = createMockServiceContext({ + errorContext: { controller: 'MarketDataService', method: 'test' }, + }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getPositions', () => { + it('fetches and returns positions successfully', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + const result = await MarketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockPositions); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Positions' }), + ); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Get Positions', + data: { success: true }, + }), + ); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('updates state with lastUpdateTimestamp on success', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + await MarketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + + it('handles errors and updates state', async () => { + const mockError = new Error('Network error'); + mockProvider.getPositions.mockRejectedValue(mockError); + + await expect( + MarketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('works without stateManager', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + const contextWithoutState = createMockServiceContext({ + errorContext: { controller: 'MarketDataService', method: 'test' }, + stateManager: undefined, + }); + + const result = await MarketDataService.getPositions({ + provider: mockProvider, + context: contextWithoutState, + }); + + expect(result).toEqual(mockPositions); + }); + + it('passes params to provider', async () => { + const mockPositions: Position[] = []; + mockProvider.getPositions.mockResolvedValue(mockPositions); + const params = { skipCache: true }; + + await MarketDataService.getPositions({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(mockProvider.getPositions).toHaveBeenCalledWith(params); + }); + + it('handles provider exception during getPositions', async () => { + const error = new Error('Network timeout'); + mockProvider.getPositions.mockRejectedValue(error); + + await expect( + MarketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + }); + + describe('getOrderFills', () => { + it('fetches and returns order fills successfully', async () => { + const mockOrderFills: OrderFill[] = [ + { + orderId: 'fill-1', + symbol: 'BTC', + side: 'buy', + price: '50000', + size: '0.1', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]; + mockProvider.getOrderFills.mockResolvedValue(mockOrderFills); + + const result = await MarketDataService.getOrderFills({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrderFills); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Order Fills Fetch' }), + ); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ data: { success: true } }), + ); + }); + + it('handles errors and logs them', async () => { + const mockError = new Error('API error'); + mockProvider.getOrderFills.mockRejectedValue(mockError); + + await expect( + MarketDataService.getOrderFills({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('API error'); + + expect(Logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + tags: expect.objectContaining({ feature: 'perps' }), + }), + ); + }); + + it('passes params to provider', async () => { + mockProvider.getOrderFills.mockResolvedValue([]); + const params = { startTime: Date.now() - 86400000, limit: 50 }; + + await MarketDataService.getOrderFills({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledWith(params); + }); + }); + + describe('getOrders', () => { + it('fetches and returns orders successfully', async () => { + const mockOrders: Order[] = [createMockOrder()]; + mockProvider.getOrders.mockResolvedValue(mockOrders); + + const result = await MarketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrders); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Orders Fetch' }), + ); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ data: { success: true } }), + ); + }); + + it('handles errors and logs them', async () => { + const mockError = new Error('Failed to fetch orders'); + mockProvider.getOrders.mockRejectedValue(mockError); + + await expect( + MarketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Failed to fetch orders'); + + expect(Logger.error).toHaveBeenCalled(); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + }); + }); + + describe('getOpenOrders', () => { + it('fetches open orders successfully', async () => { + const mockOrders: Order[] = [createMockOrder({ status: 'open' })]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + + const result = await MarketDataService.getOpenOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrders); + expect(trace).toHaveBeenCalled(); + expect(setMeasurement).toHaveBeenCalled(); + }); + + it('handles errors in open orders fetch', async () => { + const mockError = new Error('Connection timeout'); + mockProvider.getOpenOrders.mockRejectedValue(mockError); + + await expect( + MarketDataService.getOpenOrders({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Connection timeout'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('getFunding', () => { + it('fetches funding rates successfully', async () => { + const mockFunding: Funding[] = [ + { + symbol: 'BTC', + amountUsd: '10', + rate: '0.0001', + timestamp: Date.now(), + }, + ]; + mockProvider.getFunding.mockResolvedValue(mockFunding); + + const result = await MarketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockFunding); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Funding Fetch' }), + ); + }); + + it('handles funding fetch errors', async () => { + const mockError = new Error('Funding data unavailable'); + mockProvider.getFunding.mockRejectedValue(mockError); + + await expect( + MarketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Funding data unavailable'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('getAccountState', () => { + it('fetches account state and updates state', async () => { + const mockAccountState: AccountState = { + availableBalance: '10000', + totalBalance: '15000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '0.2', + }; + mockProvider.getAccountState.mockResolvedValue(mockAccountState); + + const result = await MarketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockAccountState); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Account State' }), + ); + }); + + it('throws error when account state is null', async () => { + mockProvider.getAccountState.mockResolvedValue(null as never); + + await expect( + MarketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow( + 'Failed to get account state: received null/undefined response', + ); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it('handles errors and updates error state', async () => { + const mockError = new Error('Account fetch failed'); + mockProvider.getAccountState.mockRejectedValue(mockError); + + await expect( + MarketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Account fetch failed'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + success: false, + error: 'Account fetch failed', + }), + }), + ); + }); + + it('passes source param in trace tags', async () => { + const mockAccountState: AccountState = { + availableBalance: '10000', + totalBalance: '15000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '0.2', + }; + mockProvider.getAccountState.mockResolvedValue(mockAccountState); + + await MarketDataService.getAccountState({ + provider: mockProvider, + params: { source: 'user-action' }, + context: mockContext, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ source: 'user-action' }), + }), + ); + }); + }); + + describe('getHistoricalPortfolio', () => { + it('fetches historical portfolio data successfully', async () => { + const mockResult = { + accountValue1dAgo: '9500', + timestamp: Date.now(), + }; + mockProvider.getHistoricalPortfolio.mockResolvedValue(mockResult); + + const result = await MarketDataService.getHistoricalPortfolio({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Historical Portfolio' }), + ); + }); + + it('throws error when provider does not support historical portfolio', async () => { + const providerWithoutMethod = { + ...mockProvider, + getHistoricalPortfolio: undefined, + }; + + await expect( + MarketDataService.getHistoricalPortfolio({ + provider: providerWithoutMethod as never, + context: mockContext, + }), + ).rejects.toThrow('Historical portfolio not supported by provider'); + }); + + it('handles errors and updates error state', async () => { + const mockError = new Error('Portfolio data error'); + mockProvider.getHistoricalPortfolio.mockRejectedValue(mockError); + + await expect( + MarketDataService.getHistoricalPortfolio({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Portfolio data error'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMarkets', () => { + it('fetches markets successfully', async () => { + const mockMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 20, marginTableId: 1 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 15, marginTableId: 2 }, + ]; + mockProvider.getMarkets.mockResolvedValue(mockMarkets); + + const result = await MarketDataService.getMarkets({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockMarkets); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Markets' }), + ); + }); + + it('includes symbol count in trace tags when symbols provided', async () => { + mockProvider.getMarkets.mockResolvedValue([]); + + await MarketDataService.getMarkets({ + provider: mockProvider, + params: { symbols: ['BTC', 'ETH', 'SOL'] }, + context: mockContext, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ symbolCount: 3 }), + }), + ); + }); + + it('handles market fetch errors and updates state', async () => { + const mockError = new Error('Markets unavailable'); + mockProvider.getMarkets.mockRejectedValue(mockError); + + await expect( + MarketDataService.getMarkets({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Markets unavailable'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('getAvailableDexs', () => { + it('fetches available DEXs when supported', async () => { + const mockDexs = ['hyperliquid', 'vertex']; + const providerWithDexs = { + ...mockProvider, + getAvailableDexs: jest.fn().mockResolvedValue(mockDexs), + }; + + const result = await MarketDataService.getAvailableDexs({ + provider: providerWithDexs as never, + }); + + expect(result).toEqual(mockDexs); + }); + + it('throws error when provider does not support HIP-3 DEXs', async () => { + const providerWithoutDexs = { + ...mockProvider, + getAvailableDexs: undefined, + }; + + await expect( + MarketDataService.getAvailableDexs({ + provider: providerWithoutDexs as never, + }), + ).rejects.toThrow('Provider does not support HIP-3 DEXs'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateLiquidationPrice', () => { + it('calculates liquidation price successfully', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 0.5, + }; + mockProvider.calculateLiquidationPrice.mockResolvedValue('45000'); + + const result = await MarketDataService.calculateLiquidationPrice({ + provider: mockProvider, + params, + }); + + expect(result).toBe('45000'); + expect(mockProvider.calculateLiquidationPrice).toHaveBeenCalledWith( + params, + ); + }); + + it('handles calculation errors', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 0.5, + }; + const mockError = new Error('Calculation failed'); + mockProvider.calculateLiquidationPrice.mockRejectedValue(mockError); + + await expect( + MarketDataService.calculateLiquidationPrice({ + provider: mockProvider, + params, + }), + ).rejects.toThrow('Calculation failed'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateMaintenanceMargin', () => { + it('calculates maintenance margin successfully', async () => { + const params = { + asset: 'BTC', + positionSize: 0.5, + }; + mockProvider.calculateMaintenanceMargin.mockResolvedValue(500); + + const result = await MarketDataService.calculateMaintenanceMargin({ + provider: mockProvider, + params, + }); + + expect(result).toBe(500); + }); + + it('handles maintenance margin errors', async () => { + const params = { + asset: 'BTC', + positionSize: 0.5, + }; + const mockError = new Error('Margin calculation error'); + mockProvider.calculateMaintenanceMargin.mockRejectedValue(mockError); + + await expect( + MarketDataService.calculateMaintenanceMargin({ + provider: mockProvider, + params, + }), + ).rejects.toThrow('Margin calculation error'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMaxLeverage', () => { + it('fetches max leverage for asset', async () => { + mockProvider.getMaxLeverage.mockResolvedValue(20); + + const result = await MarketDataService.getMaxLeverage({ + provider: mockProvider, + asset: 'BTC', + }); + + expect(result).toBe(20); + expect(mockProvider.getMaxLeverage).toHaveBeenCalledWith('BTC'); + }); + + it('handles max leverage errors', async () => { + const mockError = new Error('Asset not found'); + mockProvider.getMaxLeverage.mockRejectedValue(mockError); + + await expect( + MarketDataService.getMaxLeverage({ + provider: mockProvider, + asset: 'INVALID', + }), + ).rejects.toThrow('Asset not found'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateFees', () => { + it('calculates fees successfully', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + coin: 'BTC', + amount: '0.1', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0005, + feeAmount: 2.5, + protocolFeeRate: 0.0003, + protocolFeeAmount: 1.5, + metamaskFeeRate: 0.0002, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await MarketDataService.calculateFees({ + provider: mockProvider, + params, + }); + + expect(result).toEqual(mockFees); + }); + + it('handles fee calculation errors', async () => { + const params: FeeCalculationParams = { + orderType: 'limit', + coin: 'BTC', + amount: '0.1', + isMaker: true, + }; + const mockError = new Error('Fee calculation failed'); + mockProvider.calculateFees.mockRejectedValue(mockError); + + await expect( + MarketDataService.calculateFees({ + provider: mockProvider, + params, + }), + ).rejects.toThrow('Fee calculation failed'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('validateOrder', () => { + it('validates order successfully', async () => { + const params = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + const mockResult = { isValid: true }; + mockProvider.validateOrder.mockResolvedValue(mockResult); + + const result = await MarketDataService.validateOrder({ + provider: mockProvider, + params, + }); + + expect(result).toEqual(mockResult); + }); + + it('returns validation error when order invalid', async () => { + const params = { + coin: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market' as const, + }; + const mockResult = { isValid: false, error: 'Size too small' }; + mockProvider.validateOrder.mockResolvedValue(mockResult); + + const result = await MarketDataService.validateOrder({ + provider: mockProvider, + params, + }); + + expect(result).toEqual(mockResult); + }); + + it('handles validation errors', async () => { + const params = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + const mockError = new Error('Validation service unavailable'); + mockProvider.validateOrder.mockRejectedValue(mockError); + + await expect( + MarketDataService.validateOrder({ + provider: mockProvider, + params, + }), + ).rejects.toThrow('Validation service unavailable'); + + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('validateClosePosition', () => { + it('validates close position request', async () => { + const params = { + coin: 'BTC', + size: '0.5', + }; + const mockResult = { isValid: true }; + mockProvider.validateClosePosition.mockResolvedValue(mockResult); + + const result = await MarketDataService.validateClosePosition({ + provider: mockProvider, + params, + }); + + expect(result).toEqual(mockResult); + }); + + it('returns error when close position invalid', async () => { + const params = { + coin: 'BTC', + size: '10', + }; + const mockResult = { isValid: false, error: 'Position size mismatch' }; + mockProvider.validateClosePosition.mockResolvedValue(mockResult); + + const result = await MarketDataService.validateClosePosition({ + provider: mockProvider, + params, + }); + + expect(result).toEqual(mockResult); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('fetches withdrawal routes successfully', () => { + const mockRoutes: AssetRoute[] = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + chainId: 'eip155:42161', + contractAddress: '0xBridgeAddress', + constraints: { minAmount: '10' }, + }, + ]; + mockProvider.getWithdrawalRoutes.mockReturnValue(mockRoutes); + + const result = MarketDataService.getWithdrawalRoutes({ + provider: mockProvider, + }); + + expect(result).toEqual(mockRoutes); + }); + + it('returns empty array on error', () => { + mockProvider.getWithdrawalRoutes.mockImplementation(() => { + throw new Error('Routes unavailable'); + }); + + const result = MarketDataService.getWithdrawalRoutes({ + provider: mockProvider, + }); + + expect(result).toEqual([]); + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('returns block explorer URL without address', () => { + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + + const result = MarketDataService.getBlockExplorerUrl({ + provider: mockProvider, + }); + + expect(result).toBe('https://explorer.example.com'); + }); + + it('returns block explorer URL with address', () => { + const address = '0x1234'; + mockProvider.getBlockExplorerUrl.mockReturnValue( + `https://explorer.example.com/address/${address}`, + ); + + const result = MarketDataService.getBlockExplorerUrl({ + provider: mockProvider, + address, + }); + + expect(result).toBe(`https://explorer.example.com/address/${address}`); + expect(mockProvider.getBlockExplorerUrl).toHaveBeenCalledWith(address); + }); + }); + + describe('fetchHistoricalCandles', () => { + const mockCandleData: CandleData = { + coin: 'BTC', + interval: '1h' as CandlePeriod, + candles: [ + { + time: 1700000000, + open: '50000', + high: '51000', + low: '49500', + close: '50500', + volume: '1000', + }, + ], + }; + + it('fetches historical candles successfully', async () => { + const hyperLiquidProvider = mockProvider as unknown as { + clientService: { + fetchHistoricalCandles: jest.Mock; + }; + }; + hyperLiquidProvider.clientService = { + fetchHistoricalCandles: jest.fn().mockResolvedValue(mockCandleData), + }; + + const result = await MarketDataService.fetchHistoricalCandles({ + provider: mockProvider, + coin: 'BTC', + interval: '1h' as CandlePeriod, + limit: 100, + context: mockContext, + }); + + expect(result).toEqual(mockCandleData); + expect( + hyperLiquidProvider.clientService.fetchHistoricalCandles, + ).toHaveBeenCalledWith('BTC', '1h', 100); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Fetch Historical Candles' }), + ); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Fetch Historical Candles', + data: { success: true }, + }), + ); + }); + + it('throws error when provider lacks clientService support', async () => { + const providerWithoutClient = { ...mockProvider }; + + await expect( + MarketDataService.fetchHistoricalCandles({ + provider: providerWithoutClient, + coin: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Historical candles not supported by provider'); + + expect(Logger.error).toHaveBeenCalled(); + expect(endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + }); + + it('updates error state on failure', async () => { + const hyperLiquidProvider = mockProvider as unknown as { + clientService: { + fetchHistoricalCandles: jest.Mock; + }; + }; + const mockError = new Error('Network timeout'); + hyperLiquidProvider.clientService = { + fetchHistoricalCandles: jest.fn().mockRejectedValue(mockError), + }; + + await expect( + MarketDataService.fetchHistoricalCandles({ + provider: mockProvider, + coin: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/MarketDataService.ts b/app/components/UI/Perps/controllers/services/MarketDataService.ts new file mode 100644 index 00000000000..e91488bdff9 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/MarketDataService.ts @@ -0,0 +1,887 @@ +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import { + trace, + endTrace, + TraceName, + TraceOperation, +} from '../../../../../util/trace'; +import { v4 as uuidv4 } from 'uuid'; +import performance from 'react-native-performance'; +import { setMeasurement } from '@sentry/react-native'; +import { PerpsMeasurementName } from '../../constants/performanceMetrics'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; +import type { ServiceContext } from './ServiceContext'; +import type { + IPerpsProvider, + Position, + GetPositionsParams, + AccountState, + GetAccountStateParams, + HistoricalPortfolioResult, + GetHistoricalPortfolioParams, + OrderFill, + GetOrderFillsParams, + Funding, + GetFundingParams, + Order, + GetOrdersParams, + MarketInfo, + GetAvailableDexsParams, + LiquidationPriceParams, + MaintenanceMarginParams, + FeeCalculationParams, + FeeCalculationResult, + OrderParams, + ClosePositionParams, + AssetRoute, +} from '../types'; +import type { CandleData } from '../../types/perps-types'; +import type { CandlePeriod } from '../../constants/chartConfig'; + +/** + * MarketDataService + * + * Handles all read-only data-fetching operations for the Perps controller. + * This service is stateless and delegates to the provider. + * The controller is responsible for tracing and state management. + */ +export class MarketDataService { + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'MarketDataService', + method, + ...additionalContext, + }; + } + + /** + * Get current positions + * Handles full orchestration: tracing, error logging, state management, and provider delegation + */ + static async getPositions(options: { + provider: IPerpsProvider; + params?: GetPositionsParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsGetPositions, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + const positions = await provider.getPositions(params); + + // Update state on success (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + + traceData = { success: true }; + return positions; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.POSITIONS_FAILED; + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + endTrace({ + name: TraceName.PerpsGetPositions, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get order fills for a specific user or order + * Handles full orchestration: tracing, error logging, and provider delegation + */ + static async getOrderFills(options: { + provider: IPerpsProvider; + params?: GetOrderFillsParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsOrderFillsFetch, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + const result = await provider.getOrderFills(params); + + traceData = { success: true }; + return result; + } catch (error) { + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + endTrace({ + name: TraceName.PerpsOrderFillsFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get historical user orders (order lifecycle) + * Handles full orchestration: tracing, error logging, and provider delegation + */ + static async getOrders(options: { + provider: IPerpsProvider; + params?: GetOrdersParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsOrdersFetch, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + const result = await provider.getOrders(params); + + traceData = { success: true }; + return result; + } catch (error) { + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + endTrace({ + name: TraceName.PerpsOrdersFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get current open orders + * Handles full orchestration: tracing, error logging, performance measurement, and provider delegation + */ + static async getOpenOrders(options: { + provider: IPerpsProvider; + params?: GetOrdersParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + const traceSpan = trace({ + name: TraceName.PerpsOrdersFetch, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + const result = await provider.getOpenOrders(params); + + const completionDuration = performance.now() - startTime; + setMeasurement( + PerpsMeasurementName.PERPS_GET_OPEN_ORDERS_OPERATION, + completionDuration, + 'millisecond', + traceSpan, + ); + + traceData = { success: true }; + return result; + } catch (error) { + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + endTrace({ + name: TraceName.PerpsOrdersFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get funding rates + * Handles full orchestration: tracing, error logging, and provider delegation + */ + static async getFunding(options: { + provider: IPerpsProvider; + params?: GetFundingParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsFundingFetch, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + const result = await provider.getFunding(params); + + traceData = { success: true }; + return result; + } catch (error) { + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + endTrace({ + name: TraceName.PerpsFundingFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get account state + * Handles full orchestration: tracing, error logging, state management, and provider delegation + */ + static async getAccountState(options: { + provider: IPerpsProvider; + params?: GetAccountStateParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsGetAccountState, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + source: params?.source || 'unknown', + }, + }); + + const accountState = await provider.getAccountState(params); + + // Safety check for accountState + if (!accountState) { + const error = new Error( + 'Failed to get account state: received null/undefined response', + ); + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + operation: 'nullAccountStateCheck', + }, + }, + }); + + throw error; + } + + // Update state on success (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.accountState = accountState; + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + + traceData = { success: true }; + return accountState; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Account state fetch failed'; + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + endTrace({ + name: TraceName.PerpsGetAccountState, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get historical portfolio data + * Handles full orchestration: tracing, error logging, state management, and provider delegation + */ + static async getHistoricalPortfolio(options: { + provider: IPerpsProvider; + params?: GetHistoricalPortfolioParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsGetHistoricalPortfolio, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }, + }); + + if (!provider.getHistoricalPortfolio) { + throw new Error('Historical portfolio not supported by provider'); + } + + const result = await provider.getHistoricalPortfolio(params); + + traceData = { success: true }; + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to get historical portfolio'; + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }); + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + endTrace({ + name: TraceName.PerpsGetHistoricalPortfolio, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get available markets + * Handles full orchestration: tracing, error logging, state management, and provider delegation + */ + static async getMarkets(options: { + provider: IPerpsProvider; + params?: { symbols?: string[]; dex?: string }; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsGetMarkets, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + ...(params?.symbols && { symbolCount: params.symbols.length }), + ...(params?.dex !== undefined && { dex: params.dex }), + }, + }); + + const markets = await provider.getMarkets(params); + + // Clear any previous errors on successful call (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { success: true }; + return markets; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.MARKETS_FAILED; + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }); + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + endTrace({ + name: TraceName.PerpsGetMarkets, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get available DEXs (HIP-3 support required) + */ + static async getAvailableDexs(options: { + provider: IPerpsProvider; + params?: GetAvailableDexsParams; + }): Promise { + const { provider, params } = options; + + try { + if (!provider.getAvailableDexs) { + throw new Error('Provider does not support HIP-3 DEXs'); + } + + return await provider.getAvailableDexs(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('getAvailableDexs', { params }), + ); + throw error; + } + } + + /** + * Fetch historical candle data for charting + * Handles full orchestration: tracing, error logging, state management, and provider delegation + */ + static async fetchHistoricalCandles(options: { + provider: IPerpsProvider; + coin: string; + interval: CandlePeriod; + limit?: number; + context: ServiceContext; + }): Promise { + const { provider, coin, interval, limit = 100, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + trace({ + name: TraceName.PerpsFetchHistoricalCandles, + id: traceId, + op: TraceOperation.PerpsOperation, + tags: { + provider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + coin, + interval, + }, + }); + + // Check if provider supports historical candles via clientService + const hyperLiquidProvider = provider as { + clientService?: { + fetchHistoricalCandles?: ( + coin: string, + interval: CandlePeriod, + limit: number, + ) => Promise; + }; + }; + if (!hyperLiquidProvider.clientService?.fetchHistoricalCandles) { + throw new Error('Historical candles not supported by provider'); + } + + const result = + await hyperLiquidProvider.clientService.fetchHistoricalCandles( + coin, + interval, + limit, + ); + + traceData = { success: true }; + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to fetch historical candles'; + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + coin, + interval, + limit, + }, + }, + }); + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + endTrace({ + name: TraceName.PerpsFetchHistoricalCandles, + id: traceId, + data: traceData, + }); + } + } + + /** + * Calculate liquidation price for a position + */ + static async calculateLiquidationPrice(options: { + provider: IPerpsProvider; + params: LiquidationPriceParams; + }): Promise { + const { provider, params } = options; + + try { + return await provider.calculateLiquidationPrice(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('calculateLiquidationPrice', { params }), + ); + throw error; + } + } + + /** + * Calculate maintenance margin for a position + */ + static async calculateMaintenanceMargin(options: { + provider: IPerpsProvider; + params: MaintenanceMarginParams; + }): Promise { + const { provider, params } = options; + + try { + return await provider.calculateMaintenanceMargin(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('calculateMaintenanceMargin', { params }), + ); + throw error; + } + } + + /** + * Get maximum leverage for an asset + */ + static async getMaxLeverage(options: { + provider: IPerpsProvider; + asset: string; + }): Promise { + const { provider, asset } = options; + + try { + return await provider.getMaxLeverage(asset); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('getMaxLeverage', { asset }), + ); + throw error; + } + } + + /** + * Calculate fees for an order + */ + static async calculateFees(options: { + provider: IPerpsProvider; + params: FeeCalculationParams; + }): Promise { + const { provider, params } = options; + + try { + return await provider.calculateFees(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('calculateFees', { params }), + ); + throw error; + } + } + + /** + * Validate an order before placement + */ + static async validateOrder(options: { + provider: IPerpsProvider; + params: OrderParams; + }): Promise<{ isValid: boolean; error?: string }> { + const { provider, params } = options; + + try { + return await provider.validateOrder(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('validateOrder', { params }), + ); + throw error; + } + } + + /** + * Validate a position close request + */ + static async validateClosePosition(options: { + provider: IPerpsProvider; + params: ClosePositionParams; + }): Promise<{ isValid: boolean; error?: string }> { + const { provider, params } = options; + + try { + return await provider.validateClosePosition(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('validateClosePosition', { params }), + ); + throw error; + } + } + + /** + * Get supported withdrawal routes (synchronous) + */ + static getWithdrawalRoutes(options: { + provider: IPerpsProvider; + }): AssetRoute[] { + const { provider } = options; + + try { + return provider.getWithdrawalRoutes(); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('getWithdrawalRoutes'), + ); + return []; + } + } + + /** + * Get block explorer URL (synchronous) + */ + static getBlockExplorerUrl(options: { + provider: IPerpsProvider; + address?: string; + }): string { + const { provider, address } = options; + return provider.getBlockExplorerUrl(address); + } +} diff --git a/app/components/UI/Perps/controllers/services/RewardsIntegrationService.test.ts b/app/components/UI/Perps/controllers/services/RewardsIntegrationService.test.ts new file mode 100644 index 00000000000..ac1aec1e4e9 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/RewardsIntegrationService.test.ts @@ -0,0 +1,382 @@ +import { RewardsIntegrationService } from './RewardsIntegrationService'; +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; +import { formatAccountToCaipAccountId } from '../../utils/rewardsUtils'; +import Logger from '../../../../../util/Logger'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { createMockEvmAccount } from '../../__mocks__/serviceMocks'; +import type { RewardsController } from '../../../../../core/Engine/controllers/rewards-controller/RewardsController'; +import type { NetworkController } from '@metamask/network-controller'; +import type { PerpsControllerMessenger } from '../PerpsController'; + +jest.mock('../../utils/accountUtils'); +jest.mock('../../utils/rewardsUtils'); +jest.mock('../../../../../util/Logger'); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger'); + +describe('RewardsIntegrationService', () => { + let mockRewardsController: jest.Mocked; + let mockNetworkController: jest.Mocked; + let mockMessenger: jest.Mocked; + const mockEvmAccount = createMockEvmAccount(); + + beforeEach(() => { + mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + } as unknown as jest.Mocked; + + mockNetworkController = { + getNetworkClientById: jest.fn(), + } as unknown as jest.Mocked; + + mockMessenger = { + call: jest.fn(), + } as unknown as jest.Mocked; + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('calculateUserFeeDiscount', () => { + it('calculates fee discount successfully with valid discount', async () => { + const mockDiscountBips = 6500; // 65% + const mockCaipAccountId = + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'; + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: '0x1' }, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + (formatAccountToCaipAccountId as jest.Mock).mockReturnValue( + mockCaipAccountId, + ); + mockRewardsController.getPerpsDiscountForAccount.mockResolvedValue( + mockDiscountBips, + ); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBe(6500); + expect( + mockRewardsController.getPerpsDiscountForAccount, + ).toHaveBeenCalledWith(mockCaipAccountId); + expect(DevLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount calculated', + expect.objectContaining({ + discountBips: 6500, + discountPercentage: 65, + }), + ); + }); + + it('returns undefined when no discount available', async () => { + const mockCaipAccountId = + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'; + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: '0x1' }, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + (formatAccountToCaipAccountId as jest.Mock).mockReturnValue( + mockCaipAccountId, + ); + mockRewardsController.getPerpsDiscountForAccount.mockResolvedValue(0); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBe(0); + }); + + it('returns undefined when no EVM account found', async () => { + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + null, + ); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBeUndefined(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: No EVM account found for fee discount', + ); + expect( + mockRewardsController.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when chain ID not found', async () => { + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: {}, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBeUndefined(); + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + controller: 'RewardsIntegrationService', + method: 'calculateUserFeeDiscount', + }), + ); + expect( + mockRewardsController.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when network client not found', async () => { + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockImplementation( + () => null as never, + ); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBeUndefined(); + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + method: 'calculateUserFeeDiscount', + networkClientExists: false, + }), + ); + }); + + it('returns undefined when CAIP account ID formatting fails', async () => { + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: '0x1' }, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + (formatAccountToCaipAccountId as jest.Mock).mockReturnValue(null); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBeUndefined(); + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + controller: 'RewardsIntegrationService', + method: 'calculateUserFeeDiscount', + address: mockEvmAccount.address, + chainId: '0x1', + }), + ); + expect( + mockRewardsController.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when RewardsController throws error', async () => { + const mockError = new Error('Rewards API error'); + const mockCaipAccountId = + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'; + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: '0x1' }, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + (formatAccountToCaipAccountId as jest.Mock).mockReturnValue( + mockCaipAccountId, + ); + mockRewardsController.getPerpsDiscountForAccount.mockRejectedValue( + mockError, + ); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBeUndefined(); + expect(Logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + controller: 'RewardsIntegrationService', + method: 'calculateUserFeeDiscount', + }), + ); + }); + + it('returns undefined when NetworkController throws error', async () => { + const mockError = new Error('Network error'); + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockImplementation(() => { + throw mockError; + }); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(result).toBeUndefined(); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('handles different chain IDs correctly', async () => { + const chains = [ + { chainId: '0x1', name: 'Mainnet' }, + { chainId: '0x89', name: 'Polygon' }, + { chainId: '0xa4b1', name: 'Arbitrum' }, + ]; + + for (const chain of chains) { + jest.clearAllMocks(); + + const mockCaipAccountId = `eip155:${parseInt(chain.chainId, 16)}:${mockEvmAccount.address}`; + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: chain.name.toLowerCase(), + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: chain.chainId }, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + (formatAccountToCaipAccountId as jest.Mock).mockReturnValue( + mockCaipAccountId, + ); + mockRewardsController.getPerpsDiscountForAccount.mockResolvedValue( + 5000, + ); + + const result = await RewardsIntegrationService.calculateUserFeeDiscount( + { + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }, + ); + + expect(result).toBe(5000); + expect(formatAccountToCaipAccountId).toHaveBeenCalledWith( + mockEvmAccount.address, + chain.chainId, + ); + } + }); + + it('calculates discount percentage correctly in logs', async () => { + const testCases = [ + { bips: 6500, percentage: 65 }, + { bips: 5000, percentage: 50 }, + { bips: 2500, percentage: 25 }, + { bips: 1000, percentage: 10 }, + { bips: 0, percentage: 0 }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + const mockCaipAccountId = + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'; + + (getEvmAccountFromSelectedAccountGroup as jest.Mock).mockReturnValue( + mockEvmAccount, + ); + (mockMessenger.call as jest.Mock).mockReturnValue({ + selectedNetworkClientId: 'mainnet', + }); + mockNetworkController.getNetworkClientById.mockReturnValue({ + configuration: { chainId: '0x1' }, + } as unknown as ReturnType< + typeof mockNetworkController.getNetworkClientById + >); + (formatAccountToCaipAccountId as jest.Mock).mockReturnValue( + mockCaipAccountId, + ); + mockRewardsController.getPerpsDiscountForAccount.mockResolvedValue( + testCase.bips, + ); + + await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: mockRewardsController, + networkController: mockNetworkController, + messenger: mockMessenger, + }); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount calculated', + expect.objectContaining({ + discountBips: testCase.bips, + discountPercentage: testCase.percentage, + }), + ); + } + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/RewardsIntegrationService.ts b/app/components/UI/Perps/controllers/services/RewardsIntegrationService.ts new file mode 100644 index 00000000000..2c13abb4a3b --- /dev/null +++ b/app/components/UI/Perps/controllers/services/RewardsIntegrationService.ts @@ -0,0 +1,107 @@ +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; +import { formatAccountToCaipAccountId } from '../../utils/rewardsUtils'; +import Logger from '../../../../../util/Logger'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import type { RewardsController } from '../../../../../core/Engine/controllers/rewards-controller/RewardsController'; +import type { NetworkController } from '@metamask/network-controller'; +import type { PerpsControllerMessenger } from '../PerpsController'; + +/** + * RewardsIntegrationService + * + * Handles rewards-related operations and fee discount calculations. + * Stateless service that coordinates with RewardsController and NetworkController. + */ +export class RewardsIntegrationService { + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'RewardsIntegrationService', + method, + ...additionalContext, + }; + } + + /** + * Calculate user fee discount from rewards + * Returns discount in basis points (e.g., 6500 = 65% discount) + */ + static async calculateUserFeeDiscount(options: { + rewardsController: RewardsController; + networkController: NetworkController; + messenger: PerpsControllerMessenger; + }): Promise { + const { rewardsController, networkController, messenger } = options; + + try { + const evmAccount = getEvmAccountFromSelectedAccountGroup(); + + if (!evmAccount) { + DevLogger.log( + 'RewardsIntegrationService: No EVM account found for fee discount', + ); + return undefined; + } + + // Get the chain ID using proper NetworkController method + const networkState = messenger.call('NetworkController:getState'); + const selectedNetworkClientId = networkState.selectedNetworkClientId; + const networkClient = networkController.getNetworkClientById( + selectedNetworkClientId, + ); + const chainId = networkClient?.configuration?.chainId; + + if (!chainId) { + Logger.error( + new Error('Chain ID not found for fee discount calculation'), + this.getErrorContext('calculateUserFeeDiscount', { + selectedNetworkClientId, + networkClientExists: !!networkClient, + }), + ); + return undefined; + } + + const caipAccountId = formatAccountToCaipAccountId( + evmAccount.address, + chainId, + ); + + if (!caipAccountId) { + Logger.error( + new Error('Failed to format CAIP account ID for fee discount'), + this.getErrorContext('calculateUserFeeDiscount', { + address: evmAccount.address, + chainId, + selectedNetworkClientId, + }), + ); + return undefined; + } + + const discountBips = + await rewardsController.getPerpsDiscountForAccount(caipAccountId); + + DevLogger.log('RewardsIntegrationService: Fee discount calculated', { + address: evmAccount.address, + caipAccountId, + discountBips, + discountPercentage: discountBips / 100, + }); + + return discountBips; + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('calculateUserFeeDiscount'), + ); + return undefined; + } + } +} diff --git a/app/components/UI/Perps/controllers/services/ServiceContext.ts b/app/components/UI/Perps/controllers/services/ServiceContext.ts new file mode 100644 index 00000000000..b793bc47cb5 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/ServiceContext.ts @@ -0,0 +1,133 @@ +import type { IMetaMetrics } from '../../../../../core/Analytics/MetaMetrics.types'; +import type { PerpsStreamManager } from '../../providers/PerpsStreamManager'; +import type { DATA_LAKE_API_CONFIG } from '../../constants/perpsConfig'; +import type { + PerpsControllerState, + PerpsControllerMessenger, +} from '../PerpsController'; +import type { Order, Position } from '../types'; +import type { RewardsController } from '../../../../../core/Engine/controllers/rewards-controller/RewardsController'; +import type { NetworkController } from '@metamask/network-controller'; + +/** + * ServiceContext + * + * Dependency injection interface for Perps services. + * Provides all orchestration dependencies (tracing, analytics, state management) + * to services, allowing them to handle full operation logic independently. + * + * This enables: + * - Fat services with complete orchestration + * - Thin controller with pure delegation + * - Easy testing through mock contexts + * - Explicit dependency management + */ +export interface ServiceContext { + /** + * Tracing context for performance monitoring + * Used in trace() calls to tag operations + */ + tracingContext: { + provider: string; + isTestnet: boolean; + }; + + /** + * MetaMetrics instance for analytics events + * Services use this to track events directly + */ + analytics: IMetaMetrics; + + /** + * Error logging context + * Provides consistent error logging across services + */ + errorContext: { + controller: string; + method: string; + extra?: Record; + }; + + /** + * State management functions (optional) + * Only provided for operations that need to mutate controller state + * Example: Trading operations that update lastTransaction + */ + stateManager?: { + update: (updater: (state: PerpsControllerState) => void) => void; + getState: () => PerpsControllerState; + }; + + /** + * Optional dependencies - only provided when needed by specific operations + */ + + /** + * RewardsController for fee discount calculations + * Required by: TradingService (placeOrder, editOrder, closePosition) + */ + rewardsController?: RewardsController; + + /** + * NetworkController for chain ID resolution + * Required by: TradingService (fee discount calculation) + */ + networkController?: NetworkController; + + /** + * Messenger for controller communication + * Required by: TradingService (AuthenticationController:getBearerToken), DataLakeService (getBearerToken) + */ + messenger?: PerpsControllerMessenger; + + /** + * StreamManager for WebSocket subscriptions + * Required by: TradingService (cancelOrders - for order stream refresh) + */ + streamManager?: PerpsStreamManager; + + /** + * Data lake configuration + * Required by: TradingService (for reporting to data lake) + */ + dataLakeConfig?: typeof DATA_LAKE_API_CONFIG; + + /** + * Query functions for dependent data + * Required by: Operations that need to fetch related data + */ + getOpenOrders?: () => Promise; + getPositions?: () => Promise; + + /** + * Callback functions for controller-specific operations + */ + saveTradeConfiguration?: (coin: string, leverage: number) => void; + + /** + * Feature flag configuration callbacks + * Required by: FeatureFlagConfigurationService + */ + getBlockedRegionList?: () => { + list: string[]; + source: 'remote' | 'fallback'; + }; + setBlockedRegionList?: ( + list: string[], + source: 'remote' | 'fallback', + ) => void; + getHip3Config?: () => { + enabled: boolean; + allowlistMarkets: string[]; + blocklistMarkets: string[]; + source: 'remote' | 'fallback'; + }; + setHip3Config?: (config: { + enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + source: 'remote' | 'fallback'; + }) => void; + incrementHip3ConfigVersion?: () => number; + refreshEligibility?: () => Promise; +} diff --git a/app/components/UI/Perps/controllers/services/TradingService.test.ts b/app/components/UI/Perps/controllers/services/TradingService.test.ts new file mode 100644 index 00000000000..0d85e3b7476 --- /dev/null +++ b/app/components/UI/Perps/controllers/services/TradingService.test.ts @@ -0,0 +1,1648 @@ +import { TradingService } from './TradingService'; +import type { ServiceContext } from './ServiceContext'; +import type { + IPerpsProvider, + OrderParams, + OrderResult, + EditOrderParams, + CancelOrderParams, + CancelOrdersParams, + ClosePositionParams, + ClosePositionsParams, + Position, + Order, + UpdatePositionTPSLParams, +} from '../types'; +import { RewardsIntegrationService } from './RewardsIntegrationService'; +import Logger from '../../../../../util/Logger'; +import { trace, endTrace } from '../../../../../util/trace'; +import { + createMockServiceContext, + createMockPerpsControllerState, +} from '../../__mocks__/serviceMocks'; +import { createMockHyperLiquidProvider } from '../../__mocks__/providerMocks'; + +jest.mock('../../../../../util/Logger'); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger'); +jest.mock('../../../../../util/trace'); +jest.mock('@sentry/react-native'); +jest.mock('./RewardsIntegrationService'); +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); +jest.mock('react-native-performance', () => ({ + now: jest.fn(() => 1000), +})); + +describe('TradingService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockReportOrderToDataLake: jest.Mock; + let mockWithStreamPause: jest.Mock; + let mockGetPositions: jest.Mock; + let mockGetOpenOrders: jest.Mock; + let mockSaveTradeConfiguration: jest.Mock; + + const createContextWithRewards = (): ServiceContext => + createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + rewardsController: {} as never, + networkController: {} as never, + messenger: {} as never, + }); + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockSaveTradeConfiguration = jest.fn(); + mockContext = createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + saveTradeConfiguration: mockSaveTradeConfiguration, + }); + mockReportOrderToDataLake = jest.fn().mockResolvedValue(undefined); + mockWithStreamPause = jest.fn(async (callback) => await callback()); + mockGetPositions = jest.fn().mockResolvedValue([]); + mockGetOpenOrders = jest.fn().mockResolvedValue([]); + + jest.clearAllMocks(); + (trace as jest.Mock).mockReturnValue(undefined); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('placeOrder', () => { + it('places order successfully without fee discount', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('places order successfully with fee discount applied and cleared', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + const contextWithRewards = createContextWithRewards(); + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + const result = await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: contextWithRewards, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('clears fee discount when order placement fails', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const contextWithRewards = createContextWithRewards(); + + mockProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + await expect( + TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: contextWithRewards, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('Order placement failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('adds and removes order from pending state optimistically', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('saves trade configuration when leverage is provided', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockSaveTradeConfiguration).toHaveBeenCalledWith('BTC', 10); + }); + + it('tracks analytics event when order succeeds', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 5, + marketPrice: 50000, + marginUsed: 5000, + metamaskFee: 5, + metamaskFeeRate: 0.001, + feeDiscountPercentage: 0.65, + estimatedPoints: 100, + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('tracks analytics event when order fails', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Insufficient margin', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('reports order to data lake on success (fire-and-forget)', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockReportOrderToDataLake).toHaveBeenCalledWith({ + action: 'open', + coin: 'BTC', + sl_price: 45000, + tp_price: 55000, + }); + }); + + it('does not throw when data lake reporting fails', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + mockReportOrderToDataLake.mockRejectedValue(new Error('Data lake error')); + + await expect( + TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).resolves.toBeDefined(); + }); + + it('creates trace for order placement', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + id: 'mock-trace-id', + }), + ); + expect(endTrace).toHaveBeenCalled(); + }); + + it('handles order placement failure', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'Insufficient margin', + }); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient margin'); + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order placement', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const error = new Error('Network timeout'); + mockProvider.placeOrder.mockRejectedValue(error); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await expect( + TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('Network timeout'); + + expect(Logger.error).toHaveBeenCalledWith(error, expect.any(Object)); + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + + it('handles data lake reporting failure', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockReportOrderToDataLake.mockRejectedValue( + new Error('Data lake unavailable'), + ); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result.success).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(Logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Data lake unavailable' }), + expect.any(Object), + ); + }); + }); + + describe('editOrder', () => { + it('edits order successfully without fee discount', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('edits order successfully with fee discount applied and cleared', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + const contextWithRewards = createContextWithRewards(); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + const result = await TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: contextWithRewards, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics event when edit succeeds', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('tracks analytics event when edit fails', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Order not found', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('clears fee discount when edit throws exception', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + + mockProvider.editOrder.mockRejectedValue(new Error('Edit failed')); + const contextWithRewards = createContextWithRewards(); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + await expect( + TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: contextWithRewards, + }), + ).rejects.toThrow('Edit failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('handles order edit failure', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'market', + }, + }; + mockProvider.editOrder.mockResolvedValue({ + success: false, + error: 'Order not found', + }); + + const result = await TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order not found'); + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order edit', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + coin: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'market', + }, + }; + const error = new Error('Network timeout'); + mockProvider.editOrder.mockRejectedValue(error); + + await expect( + TradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(Logger.error).toHaveBeenCalledWith(error, expect.any(Object)); + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + }); + + describe('cancelOrder', () => { + it('cancels order successfully', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + coin: 'BTC', + }; + const mockResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + const result = await TradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.cancelOrder).toHaveBeenCalledWith(cancelParams); + }); + + it('tracks analytics event when cancellation succeeds', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + coin: 'BTC', + }; + const mockResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + await TradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('tracks analytics event when cancellation fails', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + coin: 'BTC', + }; + const mockResult = { + success: false, + error: 'Order not found', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + await TradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('logs error when cancellation throws exception', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + coin: 'BTC', + }; + + mockProvider.cancelOrder.mockRejectedValue(new Error('Cancel failed')); + + await expect( + TradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }), + ).rejects.toThrow('Cancel failed'); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it('handles order cancel failure', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + coin: 'BTC', + }; + mockProvider.cancelOrder.mockResolvedValue({ + success: false, + error: 'Order already filled', + }); + + const result = await TradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order already filled'); + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order cancel', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + coin: 'BTC', + }; + const error = new Error('Network error'); + mockProvider.cancelOrder.mockRejectedValue(error); + + await expect( + TradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(Logger.error).toHaveBeenCalledWith(error, expect.any(Object)); + expect(mockContext.analytics.trackEvent).toHaveBeenCalled(); + }); + }); + + describe('cancelOrders', () => { + const mockOrders: Order[] = [ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + price: '50000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567890, + }, + { + orderId: 'order-2', + symbol: 'ETH', + side: 'sell', + orderType: 'market', + detailedOrderType: 'Stop Market', + isTrigger: true, + reduceOnly: true, + price: '3000', + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + status: 'open', + timestamp: 1234567891, + }, + { + orderId: 'order-3', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + detailedOrderType: 'Take Profit Limit', + isTrigger: true, + reduceOnly: true, + price: '55000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567892, + }, + ]; + + it('cancels all orders excluding TP/SL when cancelAll is true', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + const result = await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(mockProvider.cancelOrders).toHaveBeenCalledWith([ + { coin: 'BTC', orderId: 'order-1' }, + ]); + }); + + it('allows canceling TP/SL orders when specified by orderId', async () => { + const params: CancelOrdersParams = { + orderIds: ['order-2', 'order-3'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [ + { success: true, orderId: 'order-2' }, + { success: true, orderId: 'order-3' }, + ], + }); + + const result = await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + }); + + it('cancels orders for specific coins when provided', async () => { + const params: CancelOrdersParams = { + coins: ['BTC'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + const result = await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(mockProvider.cancelOrders).toHaveBeenCalledWith([ + { coin: 'BTC', orderId: 'order-1' }, + { coin: 'BTC', orderId: 'order-3' }, + ]); + }); + + it('returns empty results when no orders match filters', async () => { + const params: CancelOrdersParams = { + coins: ['SOL'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + + const result = await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(result.results).toEqual([]); + expect(mockProvider.cancelOrders).not.toHaveBeenCalled(); + }); + + it('handles partial failures gracefully', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: false, + results: [ + { success: true, orderId: 'order-1' }, + { success: false, orderId: 'order-2', error: 'Order not found' }, + ], + }); + + const result = await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('pauses and resumes streams during batch cancellation', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(mockWithStreamPause).toHaveBeenCalled(); + }); + + it('resumes streams even when operation throws error', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + (mockProvider.cancelOrders as jest.Mock).mockRejectedValue( + new Error('Cancel failed'), + ); + + await expect( + TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }), + ).rejects.toThrow('Cancel failed'); + + expect(mockWithStreamPause).toHaveBeenCalled(); + }); + + it('uses fallback when provider does not support batch cancellation', async () => { + const params: CancelOrdersParams = { + orderIds: ['order-1', 'order-2'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + delete mockProvider.cancelOrders; + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + const result = await TradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.results).toHaveLength(2); + expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); + }); + }); + + describe('closePosition', () => { + const mockPosition: Position = { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('closes position successfully without fee discount', async () => { + const params: ClosePositionParams = { + coin: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + filledSize: '0.5', + averagePrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.closePosition).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('closes position successfully with fee discount applied and cleared', async () => { + const params: ClosePositionParams = { + coin: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + const contextWithRewards = createContextWithRewards(); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + const result = await TradingService.closePosition({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.closePosition).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics with PNL calculation', async () => { + const params: ClosePositionParams = { + coin: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + filledSize: '0.5', + averagePrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('reports order to data lake on successful close', async () => { + const params: ClosePositionParams = { + coin: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockReportOrderToDataLake).toHaveBeenCalledWith({ + action: 'close', + coin: 'BTC', + sl_price: undefined, + tp_price: undefined, + }); + }); + + it('detects direction from position size', async () => { + const shortPosition: Position = { + ...mockPosition, + size: '-0.5', + }; + const params: ClosePositionParams = { + coin: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([shortPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + direction: expect.any(String), + }), + }), + ); + }); + + it('tracks analytics on position close failure', async () => { + const params: ClosePositionParams = { + coin: 'BTC', + }; + const mockFailureResult: OrderResult = { + success: false, + error: 'Insufficient liquidity', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockFailureResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockFailureResult); + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + status: 'failed', + error_message: 'Insufficient liquidity', + }), + }), + ); + }); + }); + + describe('closePositions', () => { + const mockPositions: Position[] = [ + { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }, + { + coin: 'ETH', + size: '5.0', + entryPrice: '3000', + liquidationPrice: '2700', + leverage: { type: 'cross', value: 10 }, + marginUsed: '1500', + maxLeverage: 20, + positionValue: '15000', + returnOnEquity: '0.1', + unrealizedPnl: '1500', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + it('closes all positions when closeAll is true', async () => { + const params: ClosePositionsParams = { + closeAll: true, + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + results: [ + { success: true, orderId: 'close-1', coin: 'BTC' }, + { success: true, orderId: 'close-2', coin: 'ETH' }, + ], + }); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + }); + + it('closes specific coins when provided', async () => { + const params: ClosePositionsParams = { + coins: ['BTC'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'close-1', coin: 'BTC' }], + }); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(1); + }); + + it('returns empty results when no positions match', async () => { + const params: ClosePositionsParams = { + coins: ['SOL'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + + const result = await TradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(result.results).toEqual([]); + }); + + it('handles partial failures gracefully', async () => { + const params: ClosePositionsParams = { + closeAll: true, + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + results: [ + { success: true, orderId: 'close-1', coin: 'BTC' }, + { success: false, coin: 'ETH', error: 'Insufficient liquidity' }, + ], + }); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('uses fallback when provider does not support batch closing', async () => { + const params: ClosePositionsParams = { + coins: ['BTC'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + delete mockProvider.closePositions; + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.results).toHaveLength(1); + expect(mockProvider.closePosition).toHaveBeenCalledTimes(1); + }); + }); + + describe('updatePositionTPSL', () => { + const mockPosition: Position = { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('updates TP/SL successfully without fee discount', async () => { + const params: UpdatePositionTPSLParams = { + coin: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + const result = await TradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('updates TP/SL successfully with fee discount applied and cleared', async () => { + const params: UpdatePositionTPSLParams = { + coin: 'BTC', + takeProfitPrice: '55000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + const contextWithRewards = createContextWithRewards(); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + const result = await TradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics event when update succeeds', async () => { + const params: UpdatePositionTPSLParams = { + coin: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('tracks analytics event when update fails', async () => { + const params: UpdatePositionTPSLParams = { + coin: 'BTC', + takeProfitPrice: '55000', + }; + const mockResult: OrderResult = { + success: false, + error: 'Invalid price', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + }), + ); + }); + + it('includes direction and size in analytics', async () => { + const params: UpdatePositionTPSLParams = { + coin: 'BTC', + stopLossPrice: '45000', + trackingData: { + direction: 'long', + positionSize: 0.5, + source: 'tp_sl_view', + }, + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(undefined); + + await TradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + direction: expect.any(String), + position_size: expect.any(Number), + }), + }), + ); + }); + + it('clears fee discount when update throws exception', async () => { + const params: UpdatePositionTPSLParams = { + coin: 'BTC', + takeProfitPrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockRejectedValue( + new Error('Update failed'), + ); + const contextWithRewards = createContextWithRewards(); + ( + RewardsIntegrationService.calculateUserFeeDiscount as jest.Mock + ).mockResolvedValue(6500); + + await expect( + TradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + }), + ).rejects.toThrow('Update failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + }); +}); diff --git a/app/components/UI/Perps/controllers/services/TradingService.ts b/app/components/UI/Perps/controllers/services/TradingService.ts new file mode 100644 index 00000000000..2901f0d9bdb --- /dev/null +++ b/app/components/UI/Perps/controllers/services/TradingService.ts @@ -0,0 +1,1516 @@ +import Logger from '../../../../../util/Logger'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import { isTPSLOrder } from '../../constants/orderTypes'; +import { + trace, + endTrace, + TraceName, + TraceOperation, + type TraceContext, +} from '../../../../../util/trace'; +import { v4 as uuidv4 } from 'uuid'; +import performance from 'react-native-performance'; +import { setMeasurement } from '@sentry/react-native'; +import { PerpsMeasurementName } from '../../constants/performanceMetrics'; +import { RewardsIntegrationService } from './RewardsIntegrationService'; +import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; +import type { ServiceContext } from './ServiceContext'; +import type { + IPerpsProvider, + OrderParams, + OrderResult, + EditOrderParams, + CancelOrderParams, + CancelOrderResult, + CancelOrdersParams, + CancelOrdersResult, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + Position, + UpdatePositionTPSLParams, +} from '../types'; + +/** + * TradingService + * + * Handles trading operations with fee discount management. + * Controller is responsible for analytics, state management, and tracing. + */ +export class TradingService { + /** + * Error context helper for consistent logging + */ + private static getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'TradingService', + method, + ...additionalContext, + }; + } + + /** + * Track order result analytics event (success or failure) + */ + private static trackOrderResult(options: { + result: OrderResult | null; + error?: Error; + params: OrderParams; + context: ServiceContext; + duration: number; + }): void { + const { result, error, params, context, duration } = options; + + const status = + result?.success === true + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED; + + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_TRADE_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: status, + [PerpsEventProperties.ASSET]: params.coin, + [PerpsEventProperties.DIRECTION]: params.isBuy + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + [PerpsEventProperties.ORDER_TYPE]: params.orderType, + [PerpsEventProperties.LEVERAGE]: params.leverage || 1, + [PerpsEventProperties.ORDER_SIZE]: result?.filledSize || params.size, + [PerpsEventProperties.COMPLETION_DURATION]: duration, + [PerpsEventProperties.MARGIN_USED]: params.trackingData?.marginUsed, + [PerpsEventProperties.FEES]: params.trackingData?.totalFee, + [PerpsEventProperties.ASSET_PRICE]: + result?.averagePrice || params.trackingData?.marketPrice, + ...(params.orderType === 'limit' && { + [PerpsEventProperties.LIMIT_PRICE]: params.price, + }), + }); + + // Add success-specific properties + if (status === PerpsEventValues.STATUS.EXECUTED) { + eventBuilder.addProperties({ + [PerpsEventProperties.METAMASK_FEE]: params.trackingData?.metamaskFee, + [PerpsEventProperties.METAMASK_FEE_RATE]: + params.trackingData?.metamaskFeeRate, + [PerpsEventProperties.DISCOUNT_PERCENTAGE]: + params.trackingData?.feeDiscountPercentage, + [PerpsEventProperties.ESTIMATED_REWARDS]: + params.trackingData?.estimatedPoints, + ...(params.takeProfitPrice && { + [PerpsEventProperties.TAKE_PROFIT_PRICE]: parseFloat( + params.takeProfitPrice, + ), + }), + ...(params.stopLossPrice && { + [PerpsEventProperties.STOP_LOSS_PRICE]: parseFloat( + params.stopLossPrice, + ), + }), + }); + } else { + // Add failure-specific properties + eventBuilder.addProperties({ + [PerpsEventProperties.ERROR_MESSAGE]: + error?.message || result?.error || 'Unknown error', + }); + } + + context.analytics.trackEvent(eventBuilder.build()); + } + + /** + * Handle successful order placement (state updates, analytics, data lake reporting) + */ + private static async handleOrderSuccess(options: { + params: OrderParams; + context: ServiceContext; + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + coin: string; + sl_price?: number; + tp_price?: number; + }) => Promise<{ success: boolean; error?: string }>; + }): Promise { + const { params, context, reportOrderToDataLake } = options; + + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Save executed trade configuration for this market + if (params.leverage && context.saveTradeConfiguration) { + context.saveTradeConfiguration(params.coin, params.leverage); + } + + // Report to data lake (fire-and-forget with retry) + reportOrderToDataLake({ + action: 'open', + coin: params.coin, + sl_price: params.stopLossPrice + ? parseFloat(params.stopLossPrice) + : undefined, + tp_price: params.takeProfitPrice + ? parseFloat(params.takeProfitPrice) + : undefined, + }).catch((error) => { + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + operation: 'reportOrderToDataLake', + coin: params.coin, + }, + }, + }); + }); + } + + /** + * Execute a trading operation with fee discount context + * Ensures fee discount is always cleared after operation (success or failure) + */ + private static async withFeeDiscount(options: { + provider: IPerpsProvider; + feeDiscountBips?: number; + operation: () => Promise; + }): Promise { + const { provider, feeDiscountBips, operation } = options; + + try { + // Set discount context in provider for this operation + if (feeDiscountBips !== undefined && provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(feeDiscountBips); + DevLogger.log('TradingService: Fee discount set in provider', { + feeDiscountBips, + }); + } + + // Execute the operation + return await operation(); + } finally { + // Always clear discount context, even on exception + if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(undefined); + DevLogger.log('TradingService: Fee discount cleared from provider'); + } + } + } + + /** + * Place a new order with full orchestration + * Handles tracing, fee discounts, state management, analytics, and data lake reporting + */ + static async placeOrder(options: { + provider: IPerpsProvider; + params: OrderParams; + context: ServiceContext; + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + coin: string; + sl_price?: number; + tp_price?: number; + }) => Promise<{ success: boolean; error?: string }>; + }): Promise { + const { provider, params, context, reportOrderToDataLake } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let traceData: + | { success: boolean; error?: string; orderId?: string } + | undefined; + + try { + // Start trace for the entire operation + const traceSpan = trace({ + name: TraceName.PerpsPlaceOrder, + id: traceId, + op: TraceOperation.PerpsOrderSubmission, + tags: { + provider: context.tracingContext.provider, + orderType: params.orderType, + market: params.coin, + leverage: params.leverage || 1, + isTestnet: context.tracingContext.isTestnet, + }, + data: { + isBuy: params.isBuy, + orderPrice: params.price || '', + }, + }); + + // Calculate fee discount at execution time (fresh, secure) + const feeDiscountBips = await this.calculateFeeDiscountWithMeasurement( + traceSpan, + context, + ); + + DevLogger.log('TradingService: Fee discount calculated', { + feeDiscountBips, + hasDiscount: feeDiscountBips !== undefined, + }); + + DevLogger.log('TradingService: Submitting order to provider', { + coin: params.coin, + orderType: params.orderType, + isBuy: params.isBuy, + size: params.size, + leverage: params.leverage, + hasTP: !!params.takeProfitPrice, + hasSL: !!params.stopLossPrice, + }); + + // Execute order with fee discount management + const result = await this.withFeeDiscount({ + provider, + feeDiscountBips, + operation: () => provider.placeOrder(params), + }); + + DevLogger.log('TradingService: Provider response received', { + success: result.success, + orderId: result.orderId, + error: result.error, + }); + + // Update state and handle success/failure + const completionDuration = performance.now() - startTime; + + if (result.success) { + // Handle success: state updates, data lake reporting + await this.handleOrderSuccess({ + params, + context, + reportOrderToDataLake, + }); + traceData = { success: true, orderId: result.orderId || '' }; + } else { + traceData = { success: false, error: result.error || 'Unknown error' }; + } + + // Track analytics (success or failure) + this.trackOrderResult({ + result, + params, + context, + duration: completionDuration, + }); + + return result; + } catch (error) { + const completionDuration = performance.now() - startTime; + + // Track analytics for exception + this.trackOrderResult({ + result: null, + error: error instanceof Error ? error : undefined, + params, + context, + duration: completionDuration, + }); + + // withFeeDiscount handles fee discount cleanup automatically + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + coin: params.coin, + orderType: params.orderType, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + // Always end trace on exit (success or failure) + endTrace({ + name: TraceName.PerpsPlaceOrder, + id: traceId, + data: traceData, + }); + } + } + + /** + * Load position data with performance measurement + */ + private static async loadPositionData(options: { + coin: string; + context: ServiceContext; + traceSpan: TraceContext; + }): Promise { + const { coin, context, traceSpan } = options; + + const positionLoadStart = performance.now(); + try { + const positions = context.getPositions + ? await context.getPositions() + : []; + const position = positions.find((p) => p.coin === coin); + + setMeasurement( + PerpsMeasurementName.PERPS_GET_POSITIONS_OPERATION, + performance.now() - positionLoadStart, + 'millisecond', + traceSpan, + ); + + return position; + } catch (err) { + DevLogger.log( + 'TradingService: Could not get position data for tracking', + err, + ); + return undefined; + } + } + + /** + * Calculate close position metrics + */ + private static calculateCloseMetrics( + position: Position, + params: ClosePositionParams, + result: OrderResult, + ): { + direction: string; + closePercentage: number; + closeType: string; + orderType: string; + filledSize: number; + requestedSize: number; + isPartiallyFilled: boolean; + } { + const direction = + parseFloat(position.size) > 0 + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT; + + const filledSize = result.filledSize ? parseFloat(result.filledSize) : 0; + const requestedSize = params.size + ? parseFloat(params.size) + : Math.abs(parseFloat(position.size)); + const isPartiallyFilled = filledSize > 0 && filledSize < requestedSize; + + const orderType = params.orderType || PerpsEventValues.ORDER_TYPE.MARKET; + const closePercentage = params.size + ? (parseFloat(params.size) / Math.abs(parseFloat(position.size))) * 100 + : 100; + const closeType = + closePercentage === 100 + ? PerpsEventValues.CLOSE_TYPE.FULL + : PerpsEventValues.CLOSE_TYPE.PARTIAL; + + return { + direction, + closePercentage, + closeType, + orderType, + filledSize, + requestedSize, + isPartiallyFilled, + }; + } + + /** + * Build event properties for position close analytics + */ + private static buildCloseEventProperties( + position: Position, + params: ClosePositionParams, + metrics: { + direction: string; + closePercentage: number; + closeType: string; + orderType: string; + requestedSize: number; + }, + result: OrderResult | null, + status: string, + error?: string, + ): Record { + const baseProperties = { + [PerpsEventProperties.STATUS]: status, + [PerpsEventProperties.ASSET]: position.coin, + [PerpsEventProperties.DIRECTION]: metrics.direction, + [PerpsEventProperties.ORDER_TYPE]: metrics.orderType, + [PerpsEventProperties.ORDER_SIZE]: metrics.requestedSize, + [PerpsEventProperties.OPEN_POSITION_SIZE]: Math.abs( + parseFloat(position.size), + ), + [PerpsEventProperties.PERCENTAGE_CLOSED]: metrics.closePercentage, + [PerpsEventProperties.PNL_DOLLAR]: position.unrealizedPnl + ? parseFloat(position.unrealizedPnl) + : null, + [PerpsEventProperties.PNL_PERCENT]: position.returnOnEquity + ? parseFloat(position.returnOnEquity) * 100 + : null, + [PerpsEventProperties.FEE]: params.trackingData?.totalFee || null, + [PerpsEventProperties.METAMASK_FEE]: + params.trackingData?.metamaskFee || null, + [PerpsEventProperties.METAMASK_FEE_RATE]: + params.trackingData?.metamaskFeeRate || null, + [PerpsEventProperties.DISCOUNT_PERCENTAGE]: + params.trackingData?.feeDiscountPercentage || null, + [PerpsEventProperties.ESTIMATED_REWARDS]: + params.trackingData?.estimatedPoints || null, + [PerpsEventProperties.ASSET_PRICE]: + params.trackingData?.marketPrice || result?.averagePrice || null, + [PerpsEventProperties.LIMIT_PRICE]: + params.orderType === 'limit' ? params.price : null, + [PerpsEventProperties.RECEIVED_AMOUNT]: + params.trackingData?.receivedAmount || null, + }; + + // Add success-specific properties + if (status === PerpsEventValues.STATUS.EXECUTED) { + return { + ...baseProperties, + [PerpsEventProperties.CLOSE_TYPE]: metrics.closeType, + }; + } + + // Add error for failures + return { + ...baseProperties, + ...(error && { [PerpsEventProperties.ERROR_MESSAGE]: error }), + }; + } + + /** + * Track position close result analytics (consolidates all tracking logic) + */ + private static trackPositionCloseResult(options: { + position: Position | undefined; + result: OrderResult | null; + error?: Error; + params: ClosePositionParams; + context: ServiceContext; + duration: number; + }): void { + const { position, result, error, params, context, duration } = options; + + if (!position) { + return; + } + + const metrics = result + ? this.calculateCloseMetrics(position, params, result) + : { + direction: + parseFloat(position.size) > 0 + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + closePercentage: params.size + ? (parseFloat(params.size) / Math.abs(parseFloat(position.size))) * + 100 + : 100, + closeType: PerpsEventValues.CLOSE_TYPE.FULL, + orderType: params.orderType || PerpsEventValues.ORDER_TYPE.MARKET, + requestedSize: params.size + ? parseFloat(params.size) + : Math.abs(parseFloat(position.size)), + filledSize: 0, + isPartiallyFilled: false, + }; + + // Track partially filled event if applicable + if (result?.success && metrics.isPartiallyFilled) { + const partialProperties = this.buildCloseEventProperties( + position, + params, + metrics, + result, + PerpsEventValues.STATUS.PARTIALLY_FILLED, + ); + + context.analytics.trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, + ) + .addProperties({ + ...partialProperties, + [PerpsEventProperties.AMOUNT_FILLED]: metrics.filledSize, + [PerpsEventProperties.REMAINING_AMOUNT]: + metrics.requestedSize - metrics.filledSize, + [PerpsEventProperties.COMPLETION_DURATION]: duration, + }) + .build(), + ); + } + + // Determine status + const status = + result?.success === true + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED; + + const errorMessage = error?.message || result?.error; + + // Track main close event + const eventProperties = this.buildCloseEventProperties( + position, + params, + metrics, + result, + status, + errorMessage, + ); + + context.analytics.trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, + ) + .addProperties({ + ...eventProperties, + [PerpsEventProperties.COMPLETION_DURATION]: duration, + }) + .build(), + ); + } + + /** + * Handle data lake reporting (fire-and-forget) + */ + private static handleDataLakeReporting( + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + coin: string; + }) => Promise<{ success: boolean; error?: string }>, + coin: string, + context: ServiceContext, + ): void { + reportOrderToDataLake({ + action: 'close', + coin, + }).catch((error) => { + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + operation: 'reportOrderToDataLake', + coin, + }, + }, + }); + }); + } + + /** + * Calculate fee discount with performance measurement + * Helper method for placeOrder orchestration + */ + private static async calculateFeeDiscountWithMeasurement( + traceSpan: TraceContext, + context: ServiceContext, + ): Promise { + if ( + !context.rewardsController || + !context.networkController || + !context.messenger + ) { + return undefined; + } + + const orderExecutionFeeDiscountStartTime = performance.now(); + // Calculate fee discount only if required dependencies are available + const discountBips = + context.rewardsController && + context.networkController && + context.messenger + ? await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: context.rewardsController, + networkController: context.networkController, + messenger: context.messenger, + }) + : undefined; + const orderExecutionFeeDiscountDuration = + performance.now() - orderExecutionFeeDiscountStartTime; + + // Attach measurement to the parent trace span + setMeasurement( + PerpsMeasurementName.PERPS_REWARDS_ORDER_EXECUTION_FEE_DISCOUNT_API_CALL, + orderExecutionFeeDiscountDuration, + 'millisecond', + traceSpan, + ); + + DevLogger.log('TradingService: Fee discount API call completed', { + discountBips, + duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, + }); + + return discountBips; + } + + /** + * Edit an existing order with full orchestration + * Handles tracing, fee discounts, state management, and analytics + */ + static async editOrder(options: { + provider: IPerpsProvider; + params: EditOrderParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let traceData: + | { success: boolean; error?: string; orderId?: string } + | undefined; + + try { + trace({ + name: TraceName.PerpsEditOrder, + id: traceId, + op: TraceOperation.PerpsOrderSubmission, + tags: { + provider: context.tracingContext.provider, + orderType: params.newOrder.orderType, + market: params.newOrder.coin, + leverage: params.newOrder.leverage || 1, + isTestnet: context.tracingContext.isTestnet, + }, + data: { + isBuy: params.newOrder.isBuy, + orderPrice: params.newOrder.price || '', + }, + }); + + // Calculate fee discount only if required dependencies are available + const feeDiscountBips = + context.rewardsController && + context.networkController && + context.messenger + ? await RewardsIntegrationService.calculateUserFeeDiscount({ + rewardsController: context.rewardsController, + networkController: context.networkController, + messenger: context.messenger, + }) + : undefined; + + // Execute order edit with fee discount management + const result = await this.withFeeDiscount({ + provider, + feeDiscountBips, + operation: () => provider.editOrder(params), + }); + + const completionDuration = performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Track order edit executed + context.analytics.trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_TRADE_TRANSACTION, + ) + .addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, + [PerpsEventProperties.ASSET]: params.newOrder.coin, + [PerpsEventProperties.DIRECTION]: params.newOrder.isBuy + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + [PerpsEventProperties.ORDER_TYPE]: params.newOrder.orderType, + [PerpsEventProperties.LEVERAGE]: params.newOrder.leverage || 1, + [PerpsEventProperties.ORDER_SIZE]: params.newOrder.size, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + ...(params.newOrder.price && { + [PerpsEventProperties.LIMIT_PRICE]: parseFloat( + params.newOrder.price, + ), + }), + }) + .build(), + ); + + traceData = { success: true, orderId: result.orderId || '' }; + } else { + // Track order edit failed + context.analytics.trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_TRADE_TRANSACTION, + ) + .addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.ASSET]: params.newOrder.coin, + [PerpsEventProperties.DIRECTION]: params.newOrder.isBuy + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + [PerpsEventProperties.ORDER_TYPE]: params.newOrder.orderType, + [PerpsEventProperties.LEVERAGE]: params.newOrder.leverage || 1, + [PerpsEventProperties.ORDER_SIZE]: params.newOrder.size, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.ERROR_MESSAGE]: + result.error || 'Unknown error', + }) + .build(), + ); + + traceData = { success: false, error: result.error || 'Unknown error' }; + } + + return result; + } catch (error) { + const completionDuration = performance.now() - startTime; + + // Track order edit exception + context.analytics.trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_TRADE_TRANSACTION, + ) + .addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.ASSET]: params.newOrder.coin, + [PerpsEventProperties.DIRECTION]: params.newOrder.isBuy + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + [PerpsEventProperties.ORDER_TYPE]: params.newOrder.orderType, + [PerpsEventProperties.LEVERAGE]: params.newOrder.leverage || 1, + [PerpsEventProperties.ORDER_SIZE]: params.newOrder.size, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.ERROR_MESSAGE]: + error instanceof Error ? error.message : 'Unknown error', + }) + .build(), + ); + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + orderId: params.orderId, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + endTrace({ + name: TraceName.PerpsEditOrder, + id: traceId, + data: traceData, + }); + } + } + + /** + * Cancel a single order with full orchestration + * Handles tracing, state management, and analytics + */ + static async cancelOrder(options: { + provider: IPerpsProvider; + params: CancelOrderParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let traceData: + | { success: boolean; error?: string; orderId?: string } + | undefined; + + try { + // Start trace for the entire operation + trace({ + name: TraceName.PerpsCancelOrder, + id: traceId, + op: TraceOperation.PerpsOrderSubmission, + tags: { + provider: context.tracingContext.provider, + market: params.coin, + isTestnet: context.tracingContext.isTestnet, + }, + data: { + orderId: params.orderId, + }, + }); + + // Execute order cancellation + const result = await provider.cancelOrder(params); + const completionDuration = performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Track order cancel executed + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED, + [PerpsEventProperties.ASSET]: params.coin, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + }); + context.analytics.trackEvent(eventBuilder.build()); + + traceData = { success: true, orderId: params.orderId }; + } else { + // Track order cancel failed + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.ASSET]: params.coin, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.ERROR_MESSAGE]: result.error || 'Unknown error', + }); + context.analytics.trackEvent(eventBuilder.build()); + + traceData = { success: false, error: result.error || 'Unknown error' }; + } + + return result; + } catch (error) { + const completionDuration = performance.now() - startTime; + + // Track order cancel exception + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.ASSET]: params.coin, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.ERROR_MESSAGE]: + error instanceof Error ? error.message : 'Unknown error', + }); + context.analytics.trackEvent(eventBuilder.build()); + + Logger.error( + ensureError(error), + this.getErrorContext('cancelOrder', { coin: params.coin }), + ); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + endTrace({ + name: TraceName.PerpsCancelOrder, + id: traceId, + data: traceData, + }); + } + } + + /** + * Cancel multiple orders with full orchestration + * Handles tracing, stream pausing, filtering, batch operations, and analytics + */ + static async cancelOrders(options: { + provider: IPerpsProvider; + params: CancelOrdersParams; + context: ServiceContext; + withStreamPause: ( + operation: () => Promise, + channels: string[], + ) => Promise; + }): Promise { + const { provider, params, context, withStreamPause } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let operationResult: CancelOrdersResult | null = null; + let operationError: Error | null = null; + + try { + // Start trace for batch operation + trace({ + name: TraceName.PerpsCancelOrder, + id: traceId, + op: TraceOperation.PerpsOrderSubmission, + tags: { + provider: context.tracingContext.provider, + isBatch: 'true', + isTestnet: context.tracingContext.isTestnet, + }, + data: { + cancelAll: params.cancelAll ? 'true' : 'false', + coinCount: params.coins?.length || 0, + orderIdCount: params.orderIds?.length || 0, + }, + }); + + // Pause orders stream to prevent WebSocket updates during cancellation + operationResult = await withStreamPause(async () => { + // Get all open orders + if (!context.getOpenOrders) { + throw new Error('getOpenOrders callback not provided in context'); + } + const orders = await context.getOpenOrders(); + + // Filter orders based on params + let ordersToCancel = orders; + if (params.cancelAll || (!params.coins && !params.orderIds)) { + // Cancel all orders (excluding TP/SL orders for positions) + ordersToCancel = orders.filter( + (o) => !isTPSLOrder(o.detailedOrderType), + ); + } else if (params.orderIds && params.orderIds.length > 0) { + // Cancel specific order IDs + ordersToCancel = orders.filter((o) => + params.orderIds?.includes(o.orderId), + ); + } else if (params.coins && params.coins.length > 0) { + // Cancel orders for specific coins + ordersToCancel = orders.filter((o) => + params.coins?.includes(o.symbol), + ); + } + + if (ordersToCancel.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + // Use batch cancel if provider supports it + if (provider.cancelOrders) { + return await provider.cancelOrders( + ordersToCancel.map((order) => ({ + coin: order.symbol, + orderId: order.orderId, + })), + ); + } + + // Fallback: Cancel orders in parallel (for providers without batch support) + const results = await Promise.allSettled( + ordersToCancel.map((order) => + this.cancelOrder({ + provider, + params: { coin: order.symbol, orderId: order.orderId }, + context, + }), + ), + ); + + // Aggregate results + const successCount = results.filter( + (r) => r.status === 'fulfilled' && r.value.success, + ).length; + const failureCount = results.length - successCount; + + return { + success: successCount > 0, + successCount, + failureCount, + results: results.map((result, index) => { + let error: string | undefined; + if (result.status === 'rejected') { + error = + result.reason instanceof Error + ? result.reason.message + : 'Unknown error'; + } else if (result.status === 'fulfilled' && !result.value.success) { + error = result.value.error; + } + + return { + orderId: ordersToCancel[index].orderId, + coin: ordersToCancel[index].symbol, + success: !!( + result.status === 'fulfilled' && result.value.success + ), + error, + }; + }), + }; + }, ['orders']); // Disconnect orders stream during operation + + return operationResult; + } catch (error) { + operationError = + error instanceof Error ? error : new Error(String(error)); + Logger.error(ensureError(error), this.getErrorContext('cancelOrders')); + throw error; + } finally { + const completionDuration = performance.now() - startTime; + + // Track batch cancel event (success or failure) + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: + operationResult?.success && operationResult.successCount > 0 + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + ...(operationError && { + [PerpsEventProperties.ERROR_MESSAGE]: operationError.message, + }), + }); + context.analytics.trackEvent(eventBuilder.build()); + + endTrace({ + name: TraceName.PerpsCancelOrder, + id: traceId, + }); + } + } + + /** + * Close a single position with full orchestration + * Handles tracing, fee discounts, state management, analytics, and data lake reporting + */ + static async closePosition(options: { + provider: IPerpsProvider; + params: ClosePositionParams; + context: ServiceContext; + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + coin: string; + }) => Promise<{ success: boolean; error?: string }>; + }): Promise { + const { provider, params, context, reportOrderToDataLake } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let position: Position | undefined; + let result: OrderResult | undefined; + let traceData: + | { success: boolean; error?: string; filledSize?: string } + | undefined; + + try { + const traceSpan = trace({ + name: TraceName.PerpsClosePosition, + id: traceId, + op: TraceOperation.PerpsPositionManagement, + tags: { + provider: context.tracingContext.provider, + coin: params.coin, + closeSize: params.size || 'full', + isTestnet: context.tracingContext.isTestnet, + }, + }); + + // Load position data with measurement + position = await this.loadPositionData({ + coin: params.coin, + context, + traceSpan, + }); + + // Calculate fee discount with measurement + const feeDiscountBips = await this.calculateFeeDiscountWithMeasurement( + traceSpan, + context, + ); + + // Execute position close with fee discount management + result = await this.withFeeDiscount({ + provider, + feeDiscountBips, + operation: () => provider.closePosition(params), + }); + + const completionDuration = performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Report to data lake (fire-and-forget) + this.handleDataLakeReporting( + reportOrderToDataLake, + params.coin, + context, + ); + + traceData = { success: true, filledSize: result.filledSize || '' }; + } else { + traceData = { success: false, error: result.error || 'Unknown error' }; + } + + // Track analytics (success or failure, includes partial fills) + this.trackPositionCloseResult({ + position, + result, + params, + context, + duration: completionDuration, + }); + + return result; + } catch (error) { + const completionDuration = performance.now() - startTime; + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + + // Track analytics for exception + this.trackPositionCloseResult({ + position, + result: null, + error: error instanceof Error ? error : undefined, + params, + context, + duration: completionDuration, + }); + + Logger.error(ensureError(error), { + tags: { + feature: 'perps', + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + coin: params.coin, + }, + }, + }); + + throw error; + } finally { + // Always end trace on exit (success or failure) + endTrace({ + name: TraceName.PerpsClosePosition, + id: traceId, + data: traceData, + }); + } + } + + /** + * Close multiple positions with full orchestration + * Handles tracing, fee discounts, batch operations, and analytics + */ + static async closePositions(options: { + provider: IPerpsProvider; + params: ClosePositionsParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let operationResult: ClosePositionsResult | null = null; + let operationError: Error | null = null; + + try { + // Start trace for batch operation + const traceSpan = trace({ + name: TraceName.PerpsClosePosition, + id: traceId, + op: TraceOperation.PerpsPositionManagement, + tags: { + provider: context.tracingContext.provider, + isBatch: 'true', + isTestnet: context.tracingContext.isTestnet, + }, + data: { + closeAll: params.closeAll ? 'true' : 'false', + coinCount: params.coins?.length || 0, + }, + }); + + DevLogger.log('[closePositions] Batch method check', { + providerType: provider.protocolId, + hasBatchMethod: !!provider.closePositions, + methodType: typeof provider.closePositions, + providerKeys: Object.keys(provider).filter((k) => k.includes('close')), + }); + + // Use batch close if provider supports it (provider handles filtering) + if (provider.closePositions) { + const feeDiscountBips = await this.calculateFeeDiscountWithMeasurement( + traceSpan, + context, + ); + + operationResult = await this.withFeeDiscount({ + provider, + feeDiscountBips, + operation: async () => { + if (!provider.closePositions) { + throw new Error('closePositions method not available'); + } + return provider.closePositions(params); + }, + }); + } else { + // Fallback: Get positions, filter, and close in parallel + if (!context.getPositions) { + throw new Error('getPositions callback not provided in context'); + } + const positions = await context.getPositions(); + + const positionsToClose = + params.closeAll || !params.coins || params.coins.length === 0 + ? positions + : positions.filter((p) => params.coins?.includes(p.coin)); + + if (positionsToClose.length === 0) { + operationResult = { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + return operationResult; + } + + const results = await Promise.allSettled( + positionsToClose.map((position) => + this.closePosition({ + provider, + params: { coin: position.coin }, + context, + reportOrderToDataLake: () => Promise.resolve({ success: true }), // No-op for batch fallback + }), + ), + ); + + // Aggregate results + const successCount = results.filter( + (r) => r.status === 'fulfilled' && r.value.success, + ).length; + const failureCount = results.length - successCount; + + operationResult = { + success: successCount > 0, + successCount, + failureCount, + results: results.map((result, index) => { + let error: string | undefined; + if (result.status === 'rejected') { + error = + result.reason instanceof Error + ? result.reason.message + : 'Unknown error'; + } else if (result.status === 'fulfilled' && !result.value.success) { + error = result.value.error; + } + + return { + coin: positionsToClose[index].coin, + success: !!( + result.status === 'fulfilled' && result.value.success + ), + error, + }; + }), + }; + } + + return operationResult; + } catch (error) { + operationError = + error instanceof Error ? error : new Error(String(error)); + Logger.error( + ensureError(error), + this.getErrorContext('closePositions', { + coins: params.coins?.length || 0, + closeAll: params.closeAll, + }), + ); + throw error; + } finally { + const completionDuration = performance.now() - startTime; + + // Track batch close event (success or failure) + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, + ).addProperties({ + [PerpsEventProperties.STATUS]: + operationResult?.success && operationResult.successCount > 0 + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + ...(operationError && { + [PerpsEventProperties.ERROR_MESSAGE]: operationError.message, + }), + }); + context.analytics.trackEvent(eventBuilder.build()); + + endTrace({ + name: TraceName.PerpsClosePosition, + id: traceId, + }); + } + } + + /** + * Update TP/SL for an existing position with full orchestration + * Handles tracing, fee discounts, state management, and analytics + */ + static async updatePositionTPSL(options: { + provider: IPerpsProvider; + params: UpdatePositionTPSLParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = performance.now(); + let traceData: { success: boolean; error?: string } | undefined; + let result: OrderResult | undefined; + let errorMessage: string | undefined; + + // Extract tracking data with defaults + const direction = params.trackingData?.direction; + const positionSize = params.trackingData?.positionSize; + const source = + params.trackingData?.source || PerpsEventValues.SOURCE.TP_SL_VIEW; + + try { + const traceSpan = trace({ + name: TraceName.PerpsUpdateTPSL, + id: traceId, + op: TraceOperation.PerpsPositionManagement, + tags: { + provider: context.tracingContext.provider, + market: params.coin, + isTestnet: context.tracingContext.isTestnet, + }, + data: { + takeProfitPrice: params.takeProfitPrice || '', + stopLossPrice: params.stopLossPrice || '', + }, + }); + + // Get fee discount from rewards + const feeDiscountBips = await this.calculateFeeDiscountWithMeasurement( + traceSpan, + context, + ); + + // Execute with fee discount management + result = await this.withFeeDiscount({ + provider, + feeDiscountBips, + operation: () => provider.updatePositionTPSL(params), + }); + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + traceData = { success: true }; + } else { + errorMessage = result.error || 'Unknown error'; + traceData = { success: false, error: errorMessage }; + } + + return result; + } catch (error) { + errorMessage = error instanceof Error ? error.message : 'Unknown error'; + traceData = { success: false, error: errorMessage }; + throw error; + } finally { + const completionDuration = performance.now() - startTime; + + // Build comprehensive event properties + const eventProperties = { + [PerpsEventProperties.STATUS]: result?.success + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.ASSET]: params.coin, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + [PerpsEventProperties.SOURCE]: source, + ...(direction && { + [PerpsEventProperties.DIRECTION]: + direction === 'long' + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + }), + ...(positionSize !== undefined && { + [PerpsEventProperties.POSITION_SIZE]: positionSize, + }), + ...(params.takeProfitPrice && { + [PerpsEventProperties.TAKE_PROFIT_PRICE]: parseFloat( + params.takeProfitPrice, + ), + }), + ...(params.stopLossPrice && { + [PerpsEventProperties.STOP_LOSS_PRICE]: parseFloat( + params.stopLossPrice, + ), + }), + ...(errorMessage && { + [PerpsEventProperties.ERROR_MESSAGE]: errorMessage, + }), + }; + + // Track event once with all properties + const eventBuilder = MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_RISK_MANAGEMENT, + ).addProperties(eventProperties); + context.analytics.trackEvent(eventBuilder.build()); + + endTrace({ + name: TraceName.PerpsUpdateTPSL, + id: traceId, + data: traceData, + }); + } + } +} diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index b66cf7f0f62..09744dbfb21 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -51,6 +51,7 @@ export { usePerpsTPSLUpdate } from './usePerpsTPSLUpdate'; export { usePerpsClosePosition } from './usePerpsClosePosition'; export { usePerpsOrderFees, formatFeeRate } from './usePerpsOrderFees'; export { usePerpsRewards } from './usePerpsRewards'; +export { usePerpsRewardAccountOptedIn } from './usePerpsRewardAccountOptedIn'; export { usePerpsCloseAllCalculations } from './usePerpsCloseAllCalculations'; export { usePerpsCancelAllOrders } from './usePerpsCancelAllOrders'; export { usePerpsCloseAllPositions } from './usePerpsCloseAllPositions'; diff --git a/app/components/UI/Perps/hooks/usePerpsRewardAccountOptedIn.test.ts b/app/components/UI/Perps/hooks/usePerpsRewardAccountOptedIn.test.ts new file mode 100644 index 00000000000..845457b4986 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsRewardAccountOptedIn.test.ts @@ -0,0 +1,497 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { usePerpsRewardAccountOptedIn } from './usePerpsRewardAccountOptedIn'; +import Engine from '../../../../core/Engine'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils'; +import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; +import { InternalAccount } from '@metamask/keyring-internal-api'; + +// Mock react-redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +// Mock Engine +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, +})); + +// Mock selectors +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(), +})); + +// Mock utility functions +jest.mock('../../../../core/Multichain/utils', () => ({ + getFormattedAddressFromInternalAccount: jest.fn(), +})); + +jest.mock('../utils/rewardsUtils', () => ({ + formatAccountToCaipAccountId: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectSelectedInternalAccountByScope = + selectSelectedInternalAccountByScope as jest.MockedFunction< + typeof selectSelectedInternalAccountByScope + >; +const mockGetFormattedAddressFromInternalAccount = + getFormattedAddressFromInternalAccount as jest.MockedFunction< + typeof getFormattedAddressFromInternalAccount + >; +const mockFormatAccountToCaipAccountId = + formatAccountToCaipAccountId as jest.MockedFunction< + typeof formatAccountToCaipAccountId + >; +const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockEngineSubscribe = Engine.controllerMessenger + .subscribe as jest.MockedFunction< + typeof Engine.controllerMessenger.subscribe +>; +const mockEngineUnsubscribe = Engine.controllerMessenger + .unsubscribe as jest.MockedFunction< + typeof Engine.controllerMessenger.unsubscribe +>; + +describe('usePerpsRewardAccountOptedIn', () => { + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockCaipAccount = 'eip155:1:0x1234567890123456789012345678901234567890'; + const mockAccount: InternalAccount = { + id: 'test-account-id', + address: mockAddress, + type: 'eip155:eoa', + scopes: ['eip155:1'], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + importTime: 1234567890, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + // selectSelectedInternalAccountByScope is a selector that returns a function + // When used with useSelector, it should return a function that takes a scope + const scopeSelector = (scope: string) => { + if (scope === 'eip155:1') { + return mockAccount; + } + return undefined; + }; + + mockSelectSelectedInternalAccountByScope.mockReturnValue(scopeSelector); + + mockUseSelector.mockImplementation((selector) => { + // When selector is selectSelectedInternalAccountByScope, return the scope selector function + if (selector === selectSelectedInternalAccountByScope) { + return scopeSelector; + } + return undefined; + }); + + mockGetFormattedAddressFromInternalAccount.mockReturnValue(mockAddress); + mockFormatAccountToCaipAccountId.mockReturnValue(mockCaipAccount); + }); + + describe('Initial state and account selection', () => { + it('returns null accountOptedIn when account is missing', async () => { + const emptyScopeSelector = () => undefined; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSelectedInternalAccountByScope) { + return emptyScopeSelector; + } + return undefined; + }); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + + expect(result.current.account).toBeUndefined(); + }); + + it('returns null accountOptedIn when address is missing', async () => { + mockGetFormattedAddressFromInternalAccount.mockReturnValue( + undefined as unknown as string, + ); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + }); + + it('returns selected account when available', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.account).toEqual(mockAccount); + }); + }); + }); + + describe('Rewards feature enabled check', () => { + it('returns null when rewards feature is disabled', async () => { + mockEngineCall.mockResolvedValueOnce(false); // isRewardsFeatureEnabled + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:isRewardsFeatureEnabled', + ); + expect(mockEngineCall).not.toHaveBeenCalledWith( + 'RewardsController:getCandidateSubscriptionId', + ); + }); + + it('proceeds to check subscription when rewards feature is enabled', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:isRewardsFeatureEnabled', + ); + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getCandidateSubscriptionId', + ); + }); + }); + }); + + describe('Subscription check', () => { + it('returns null when no subscription exists', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce(null); // getCandidateSubscriptionId + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getCandidateSubscriptionId', + ); + expect(mockEngineCall).not.toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + ); + }); + + it('proceeds to check opt-in when subscription exists', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getCandidateSubscriptionId', + ); + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + mockCaipAccount, + ); + }); + }); + }); + + describe('CAIP account formatting', () => { + it('returns null when CAIP formatting fails', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockFormatAccountToCaipAccountId.mockReturnValue(null); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + + expect(mockFormatAccountToCaipAccountId).toHaveBeenCalledWith( + mockAddress, + '1', + ); + expect(mockEngineCall).not.toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + expect.anything(), + ); + }); + + it('uses formatted CAIP account for opt-in check', async () => { + const customCaipAccount = 'eip155:1:0xCustomAddress'; + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockFormatAccountToCaipAccountId.mockReturnValue(customCaipAccount); + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + customCaipAccount, + ); + }); + }); + }); + + describe('Account opt-in status', () => { + it('returns true when account has opted in', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBe(true); + }); + }); + + it('returns false when account has not opted in and opt-in is supported', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(false); // getHasAccountOptedIn + mockEngineCall.mockResolvedValueOnce(true); // isOptInSupported + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBe(false); + }); + + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:isOptInSupported', + mockAccount, + ); + }); + + it('returns null when account has not opted in and opt-in is not supported', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(false); // getHasAccountOptedIn + mockEngineCall.mockResolvedValueOnce(false); // isOptInSupported + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:isOptInSupported', + mockAccount, + ); + }); + }); + + describe('Error handling', () => { + it('returns null when isRewardsFeatureEnabled throws error', async () => { + mockEngineCall.mockRejectedValueOnce(new Error('Feature check failed')); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + }); + + it('returns null when getCandidateSubscriptionId throws error', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockRejectedValueOnce( + new Error('Subscription check failed'), + ); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + }); + + it('returns null when getHasAccountOptedIn throws error', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockRejectedValueOnce(new Error('Opt-in check failed')); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + }); + + it('returns null when isOptInSupported throws error', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(false); // getHasAccountOptedIn + mockEngineCall.mockRejectedValueOnce( + new Error('Opt-in support check failed'), + ); + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBeNull(); + }); + }); + }); + + describe('Account linked event subscription', () => { + it('subscribes to account linked event on mount', () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + renderHook(() => usePerpsRewardAccountOptedIn()); + + expect(mockEngineSubscribe).toHaveBeenCalledWith( + 'RewardsController:accountLinked', + expect.any(Function), + ); + }); + + it('unsubscribes from account linked event on unmount', () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-id'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + const { unmount } = renderHook(() => usePerpsRewardAccountOptedIn()); + + const subscribeCall = mockEngineSubscribe.mock.calls[0]; + const handler = subscribeCall[1] as () => void; + + unmount(); + + expect(mockEngineUnsubscribe).toHaveBeenCalledWith( + 'RewardsController:accountLinked', + handler, + ); + }); + + it('rechecks opt-in status when account linked event fires', async () => { + mockEngineCall.mockResolvedValue(true); // All calls succeed + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBe(true); + }); + + const initialCallCount = mockEngineCall.mock.calls.length; + + // Simulate account linked event + const subscribeCall = mockEngineSubscribe.mock.calls[0]; + const handler = subscribeCall[1] as () => void; + + await act(async () => { + handler(); + }); + + await waitFor(() => { + expect(mockEngineCall.mock.calls.length).toBeGreaterThan( + initialCallCount, + ); + }); + }); + }); + + describe('Trigger parameter', () => { + it('rechecks opt-in status when trigger changes', async () => { + mockEngineCall.mockResolvedValue(true); // All calls succeed + + const { result, rerender } = renderHook( + ({ trigger }) => usePerpsRewardAccountOptedIn(trigger), + { + initialProps: { trigger: 'initial' }, + }, + ); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBe(true); + }); + + const initialCallCount = mockEngineCall.mock.calls.length; + + // Change trigger + rerender({ trigger: 'updated' }); + + await waitFor(() => { + expect(mockEngineCall.mock.calls.length).toBeGreaterThan( + initialCallCount, + ); + }); + }); + + it('handles undefined trigger', async () => { + mockEngineCall.mockResolvedValue(true); // All calls succeed + + const { result } = renderHook(() => + usePerpsRewardAccountOptedIn(undefined), + ); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBe(true); + }); + }); + }); + + describe('Integration scenarios', () => { + it('handles complete flow from account selection to opt-in check', async () => { + mockEngineCall.mockResolvedValueOnce(true); // isRewardsFeatureEnabled + mockEngineCall.mockResolvedValueOnce('subscription-123'); // getCandidateSubscriptionId + mockEngineCall.mockResolvedValueOnce(true); // getHasAccountOptedIn + + const { result } = renderHook(() => usePerpsRewardAccountOptedIn()); + + await waitFor(() => { + expect(result.current.accountOptedIn).toBe(true); + expect(result.current.account).toEqual(mockAccount); + }); + + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:isRewardsFeatureEnabled', + ); + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getCandidateSubscriptionId', + ); + expect(mockFormatAccountToCaipAccountId).toHaveBeenCalledWith( + mockAddress, + '1', + ); + expect(mockEngineCall).toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + mockCaipAccount, + ); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsRewardAccountOptedIn.ts b/app/components/UI/Perps/hooks/usePerpsRewardAccountOptedIn.ts new file mode 100644 index 00000000000..94c0d4a27d8 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsRewardAccountOptedIn.ts @@ -0,0 +1,123 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils'; +import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; +import { InternalAccount } from '@metamask/keyring-internal-api'; + +interface UsePerpsRewardAccountOptedInResult { + /** Whether the account has opted in to rewards */ + accountOptedIn: boolean | null; + /** The account that is currently in scope */ + account: InternalAccount | null | undefined; +} + +/** + * Hook for checking if the current account has opted in to rewards. + * Handles all opt-in status checking logic and subscribes to account linked events. + */ +export const usePerpsRewardAccountOptedIn = ( + /** Optional trigger to re-check opt-in status when changed */ + trigger?: unknown, +): UsePerpsRewardAccountOptedInResult => { + const [accountOptedIn, setAccountOptedIn] = useState(null); + const selectedAccount = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:1', + ); + const selectedAddress = selectedAccount + ? getFormattedAddressFromInternalAccount(selectedAccount) + : undefined; + + /** + * Check opt-in status and determine if rewards row should be shown + */ + const checkOptInStatus = useCallback(async () => { + // Skip if missing required data + if (!selectedAddress || !selectedAccount) { + setAccountOptedIn(null); + return; + } + + try { + // Check if rewards feature is enabled + const isRewardsEnabled = await Engine.controllerMessenger.call( + 'RewardsController:isRewardsFeatureEnabled', + ); + + if (!isRewardsEnabled) { + setAccountOptedIn(null); + return; + } + + // Check if there's a subscription first + const firstSubscriptionId = await Engine.controllerMessenger.call( + 'RewardsController:getCandidateSubscriptionId', + ); + + if (!firstSubscriptionId) { + setAccountOptedIn(null); + return; + } + + // Format account to CAIP-10 for Ethereum mainnet (chainId: '1') + const caipAccount = formatAccountToCaipAccountId(selectedAddress, '1'); + if (!caipAccount) { + setAccountOptedIn(null); + return; + } + + // Check if account has opted in + const hasOptedIn = await Engine.controllerMessenger.call( + 'RewardsController:getHasAccountOptedIn', + caipAccount, + ); + + // Determine if we should show the rewards row + // Show row if: opted in OR (not opted in AND opt-in is supported) + let coercedHasOptedIn: boolean | null = hasOptedIn; + + if (!hasOptedIn && selectedAccount) { + const isOptInSupported = await Engine.controllerMessenger.call( + 'RewardsController:isOptInSupported', + selectedAccount, + ); + coercedHasOptedIn = isOptInSupported ? hasOptedIn : null; + } + + setAccountOptedIn(coercedHasOptedIn); + } catch (error) { + // On error, default to not showing rewards row + setAccountOptedIn(null); + } + }, [selectedAddress, selectedAccount]); + + // Check opt-in status when dependencies change + useEffect(() => { + checkOptInStatus(); + }, [checkOptInStatus, trigger]); + + // Subscribe to account linked event to retrigger opt-in check + useEffect(() => { + const handleAccountLinked = () => { + checkOptInStatus(); + }; + + Engine.controllerMessenger.subscribe( + 'RewardsController:accountLinked', + handleAccountLinked, + ); + + return () => { + Engine.controllerMessenger.unsubscribe( + 'RewardsController:accountLinked', + handleAccountLinked, + ); + }; + }, [checkOptInStatus]); + + return { + accountOptedIn, + account: selectedAccount, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsRewards.test.ts b/app/components/UI/Perps/hooks/usePerpsRewards.test.ts index d7a297ae410..62d76def873 100644 --- a/app/components/UI/Perps/hooks/usePerpsRewards.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsRewards.test.ts @@ -1,6 +1,7 @@ import { renderHook, act } from '@testing-library/react-native'; import { usePerpsRewards } from './usePerpsRewards'; import type { OrderFeesResult } from './usePerpsOrderFees'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; // Mock the development config jest.mock('../constants/perpsConfig', () => ({ @@ -10,6 +11,17 @@ jest.mock('../constants/perpsConfig', () => ({ }, })); +// Mock the usePerpsRewardAccountOptedIn hook +jest.mock('./usePerpsRewardAccountOptedIn', () => ({ + usePerpsRewardAccountOptedIn: jest.fn(), +})); + +import { usePerpsRewardAccountOptedIn } from './usePerpsRewardAccountOptedIn'; + +const mockUsePerpsRewardAccountOptedIn = jest.mocked( + usePerpsRewardAccountOptedIn, +); + describe('usePerpsRewards', () => { // Mock fee results for testing const createMockFeeResults = ( @@ -29,13 +41,35 @@ describe('usePerpsRewards', () => { ...overrides, }); + // Mock account for testing + const createMockAccount = (): InternalAccount => + ({ + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + }) as InternalAccount; + beforeEach(() => { jest.clearAllMocks(); + // Default mock: account opted in, with a mock account + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: createMockAccount(), + }); }); describe('Rewards row visibility', () => { - it('should show rewards row when has valid amount', () => { + it('should show rewards row when has valid amount and account opted in', () => { // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: createMockAccount(), + }); const feeResults = createMockFeeResults({ estimatedPoints: 100 }); // Act @@ -51,10 +85,61 @@ describe('usePerpsRewards', () => { // Assert expect(result.current.shouldShowRewardsRow).toBe(true); expect(result.current.estimatedPoints).toBe(100); + expect(result.current.accountOptedIn).toBe(true); + }); + + it('should show rewards row when has valid amount and account not opted in', () => { + // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: false, + account: createMockAccount(), + }); + const feeResults = createMockFeeResults({ estimatedPoints: 100 }); + + // Act + const { result } = renderHook(() => + usePerpsRewards({ + feeResults, + hasValidAmount: true, + isFeesLoading: false, + orderAmount: '1000', + }), + ); + + // Assert + expect(result.current.shouldShowRewardsRow).toBe(true); + expect(result.current.accountOptedIn).toBe(false); + }); + + it('should not show rewards row when accountOptedIn is null', () => { + // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: null, + account: null, + }); + const feeResults = createMockFeeResults({ estimatedPoints: 100 }); + + // Act + const { result } = renderHook(() => + usePerpsRewards({ + feeResults, + hasValidAmount: true, + isFeesLoading: false, + orderAmount: '1000', + }), + ); + + // Assert + expect(result.current.shouldShowRewardsRow).toBe(false); + expect(result.current.accountOptedIn).toBeNull(); }); it('should not show rewards row when hasValidAmount is false', () => { // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: createMockAccount(), + }); const feeResults = createMockFeeResults({ estimatedPoints: 100 }); // Act @@ -394,6 +479,11 @@ describe('usePerpsRewards', () => { describe('Return values', () => { it('should return all expected properties from fee results', () => { // Arrange + const mockAccount = createMockAccount(); + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: mockAccount, + }); const feeResults = createMockFeeResults({ estimatedPoints: 1500, bonusBips: 250, @@ -414,10 +504,16 @@ describe('usePerpsRewards', () => { expect(result.current.estimatedPoints).toBe(1500); expect(result.current.bonusBips).toBe(250); expect(result.current.feeDiscountPercentage).toBe(15); + expect(result.current.accountOptedIn).toBe(true); + expect(result.current.account).toEqual(mockAccount); }); it('should handle undefined values gracefully', () => { // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: false, + account: createMockAccount(), + }); const feeResults = createMockFeeResults({ estimatedPoints: undefined, bonusBips: undefined, @@ -438,12 +534,64 @@ describe('usePerpsRewards', () => { expect(result.current.estimatedPoints).toBeUndefined(); expect(result.current.bonusBips).toBeUndefined(); expect(result.current.feeDiscountPercentage).toBeUndefined(); + expect(result.current.accountOptedIn).toBe(false); + }); + + it('should return accountOptedIn and account from usePerpsRewardAccountOptedIn', () => { + // Arrange + const mockAccount = createMockAccount(); + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: mockAccount, + }); + const feeResults = createMockFeeResults({ estimatedPoints: 100 }); + + // Act + const { result } = renderHook(() => + usePerpsRewards({ + feeResults, + hasValidAmount: true, + isFeesLoading: false, + orderAmount: '1000', + }), + ); + + // Assert + expect(result.current.accountOptedIn).toBe(true); + expect(result.current.account).toEqual(mockAccount); + }); + + it('should return null account when accountOptedIn is null', () => { + // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: null, + account: null, + }); + const feeResults = createMockFeeResults({ estimatedPoints: 100 }); + + // Act + const { result } = renderHook(() => + usePerpsRewards({ + feeResults, + hasValidAmount: true, + isFeesLoading: false, + orderAmount: '1000', + }), + ); + + // Assert + expect(result.current.accountOptedIn).toBeNull(); + expect(result.current.account).toBeNull(); }); }); describe('Edge cases', () => { it('should handle empty order amount', () => { // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: createMockAccount(), + }); const feeResults = createMockFeeResults({ estimatedPoints: 100 }); // Act @@ -464,6 +612,10 @@ describe('usePerpsRewards', () => { it('should handle transitions from points to no points', () => { // Arrange + mockUsePerpsRewardAccountOptedIn.mockReturnValue({ + accountOptedIn: true, + account: createMockAccount(), + }); const initialFeeResults = createMockFeeResults({ estimatedPoints: 100 }); const { result, rerender } = renderHook( diff --git a/app/components/UI/Perps/hooks/usePerpsRewards.ts b/app/components/UI/Perps/hooks/usePerpsRewards.ts index e8f28bb4a64..101b9939a3f 100644 --- a/app/components/UI/Perps/hooks/usePerpsRewards.ts +++ b/app/components/UI/Perps/hooks/usePerpsRewards.ts @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; +import { InternalAccount } from '@metamask/keyring-internal-api'; import { DEVELOPMENT_CONFIG } from '../constants/perpsConfig'; import { OrderFeesResult } from './usePerpsOrderFees'; +import { usePerpsRewardAccountOptedIn } from './usePerpsRewardAccountOptedIn'; interface UsePerpsRewardsParams { /** Result from usePerpsOrderFees hook containing rewards data */ @@ -28,6 +30,10 @@ interface UsePerpsRewardsResult { hasError: boolean; /** Whether this is a refresh operation (points value changed) */ isRefresh: boolean; + /** Whether the account has opted in to rewards */ + accountOptedIn: boolean | null; + /** The account that is currently in scope */ + account?: InternalAccount | null; } /** @@ -43,6 +49,10 @@ export const usePerpsRewards = ({ // Track previous points to detect refresh state const [previousPoints, setPreviousPoints] = useState(); + // Use the extracted hook for opt-in status + const { accountOptedIn, account: selectedAccount } = + usePerpsRewardAccountOptedIn(feeResults?.estimatedPoints); + // Development-only simulations for testing different states // Amount "42": Triggers error state to test error handling UI const shouldSimulateError = useMemo( @@ -63,9 +73,10 @@ export const usePerpsRewards = ({ ); // Determine if we should show rewards row + // Show row if: has valid amount AND (opt-in check passed OR we're still checking) const shouldShowRewardsRow = useMemo( - () => hasValidAmount, // Show row if we have valid amount (even if there's an error or points are undefined) - [hasValidAmount], + () => hasValidAmount && accountOptedIn !== null, + [hasValidAmount, accountOptedIn], ); // Determine loading state @@ -116,5 +127,7 @@ export const usePerpsRewards = ({ feeDiscountPercentage: feeResults.feeDiscountPercentage, hasError, isRefresh, + accountOptedIn, + account: selectedAccount, }; }; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index 2c1b6f435d6..b6df2d4b544 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -13,6 +13,7 @@ import type { import type { HyperLiquidClientService } from './HyperLiquidClientService'; import { HyperLiquidSubscriptionService } from './HyperLiquidSubscriptionService'; import type { HyperLiquidWalletService } from './HyperLiquidWalletService'; +import { adaptAccountStateFromSDK } from '../utils/hyperLiquidAdapter'; // Mock HyperLiquid SDK types interface MockSubscription { @@ -2738,4 +2739,277 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe2(); }); }); + + describe('aggregateAccountStates - returnOnEquity calculation', () => { + it('calculates positive ROE when unrealizedPnl is positive', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '100', + totalBalance: '1100', + marginUsed: '1000', + unrealizedPnl: '100', + returnOnEquity: '10.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1000'); + expect(accountState.unrealizedPnl).toBe('100'); + expect(accountState.returnOnEquity).toBe('10.0'); + + unsubscribe(); + }); + + it('calculates negative ROE when unrealizedPnl is negative', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '0', + totalBalance: '950', + marginUsed: '1000', + unrealizedPnl: '-50', + returnOnEquity: '-5.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1000'); + expect(accountState.unrealizedPnl).toBe('-50'); + expect(accountState.returnOnEquity).toBe('-5.0'); + + unsubscribe(); + }); + + it('returns zero ROE when marginUsed is zero', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '1000', + totalBalance: '1000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('0'); + expect(accountState.unrealizedPnl).toBe('0'); + expect(accountState.returnOnEquity).toBe('0'); + + unsubscribe(); + }); + + it('calculates correct ROE with mixed profit and loss positions', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '75', + totalBalance: '1575', + marginUsed: '1500', + unrealizedPnl: '75', + returnOnEquity: '5.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 - simulates account with multiple positions + // marginUsed=1500, unrealizedPnl=75 → ROE=5.0% + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1500'); + expect(accountState.unrealizedPnl).toBe('75'); + expect(accountState.returnOnEquity).toBe('5.0'); + + unsubscribe(); + }); + + it('calculates high ROE with large percentage gains', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '200', + totalBalance: '300', + marginUsed: '100', + unrealizedPnl: '200', + returnOnEquity: '200.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('100'); + expect(accountState.unrealizedPnl).toBe('200'); + expect(accountState.returnOnEquity).toBe('200.0'); + + unsubscribe(); + }); + + it('rounds ROE to one decimal place', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '100', + totalBalance: '433', + marginUsed: '333', + unrealizedPnl: '100', + returnOnEquity: '30.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('333'); + expect(accountState.unrealizedPnl).toBe('100'); + expect(accountState.returnOnEquity).toBe('30.0'); + + unsubscribe(); + }); + }); }); diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 938c29e6453..cd19e2f2b7c 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -495,6 +495,14 @@ export class HyperLiquidSubscriptionService { const firstDexAccount = this.dexAccountCache.values().next().value || ({} as AccountState); + // Calculate returnOnEquity across all DEXs (same formula as HyperLiquidProvider.getAccountState) + let returnOnEquity = '0'; + if (totalMarginUsed > 0) { + returnOnEquity = ((totalUnrealizedPnl / totalMarginUsed) * 100).toFixed( + 1, + ); + } + return { ...firstDexAccount, availableBalance: totalAvailableBalance.toString(), @@ -502,6 +510,7 @@ export class HyperLiquidSubscriptionService { marginUsed: totalMarginUsed.toString(), unrealizedPnl: totalUnrealizedPnl.toString(), subAccountBreakdown, + returnOnEquity, }; } diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx index 6f7f063273e..f1a4760c378 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx @@ -15,144 +15,120 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: (className: string) => ({ className }), - }), -})); - -jest.mock('@metamask/design-system-react-native', () => { - const ReactActual = jest.requireActual('react'); - const { Text: RNText } = jest.requireActual('react-native'); - return { - Box: 'Box', - Text: 'Text', - TextVariant: { - BodyMd: 'BodyMd', - BodySm: 'BodySm', - }, - BoxAlignItems: { Start: 'start' }, - BoxJustifyContent: { Between: 'between' }, - BoxFlexDirection: { Row: 'row' }, - IconName: { Activity: 'Activity' }, - Icon: ({ name }: { name: string }) => - ReactActual.createElement(RNText, null, `Icon:${name}`), - }; -}); - -jest.mock('expo-image', () => ({ - Image: ({ accessibilityLabel }: { accessibilityLabel?: string }) => { - const ReactActual = jest.requireActual('react'); - const { Text: RNText } = jest.requireActual('react-native'); - return ReactActual.createElement(RNText, { accessibilityLabel }, 'image'); - }, -})); - -// Mock navigation const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); -const baseItem: PredictActivityItem = { - id: '1', - type: PredictActivityType.BUY, - marketTitle: 'Will ETF be approved?', - detail: '$123.45 on Yes â€ĸ 34Âĸ', - amountUsd: 1234.5, - percentChange: 1.5, - icon: undefined, - outcome: 'Yes', - entry: { - type: 'buy', +const createActivityItem = ( + overrides?: Partial, +): PredictActivityItem => { + const baseEntry = { + type: 'buy' as const, timestamp: 0, marketId: 'market-1', outcomeId: 'outcome-1', outcomeTokenId: 0, amount: 1234.5, price: 0.34, - }, -}; + }; -const renderComponent = (overrides?: Partial) => { - const item: PredictActivityItem = { - ...baseItem, + return { + id: '1', + type: PredictActivityType.BUY, + marketTitle: 'Will ETF be approved?', + detail: '$123.45 on Yes â€ĸ 34Âĸ', + amountUsd: 1234.5, + percentChange: 1.5, + icon: undefined, + outcome: 'Yes', + entry: baseEntry, ...overrides, - entry: { - ...baseItem.entry, - ...(overrides?.entry ?? {}), - }, }; - render(); - return { item }; }; describe('PredictActivity', () => { - it('renders BUY activity with title, market, amount and percent', () => { - renderComponent(); - - expect(screen.getByText('Buy')).toBeOnTheScreen(); - expect(screen.getByText(baseItem.marketTitle)).toBeOnTheScreen(); - expect(screen.getByText('-$1,234.50')).toBeOnTheScreen(); - expect(screen.getByText('1.5%')).toBeOnTheScreen(); + beforeEach(() => { + jest.clearAllMocks(); }); - it('renders SELL activity with plus-signed amount and negative percent', () => { - renderComponent({ - type: PredictActivityType.SELL, - percentChange: -3, - entry: { - type: 'sell', - timestamp: 0, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 0, - amount: 1234.5, - price: 0.34, - }, - }); + describe('BUY activity', () => { + it('displays buy title with market information and detail', () => { + const item = createActivityItem(); - expect(screen.getByText('Sell')).toBeOnTheScreen(); - expect(screen.getByText('+$1,234.50')).toBeOnTheScreen(); - expect(screen.getByText('-3%')).toBeOnTheScreen(); - }); + render(); - it('renders CLAIM activity without detail', () => { - renderComponent({ - type: PredictActivityType.CLAIM, - entry: { - type: 'claimWinnings', - timestamp: 0, - amount: 1234.5, - }, + expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Will ETF be approved?')).toBeOnTheScreen(); + expect(screen.getByText('-$1,234.50')).toBeOnTheScreen(); + expect(screen.getByText('1.5%')).toBeOnTheScreen(); }); - expect(screen.getByText('Claim')).toBeOnTheScreen(); - expect(screen.queryByText(baseItem.detail)).toBeNull(); - }); + it('displays custom icon when icon URL is provided', () => { + const item = createActivityItem({ + icon: 'https://example.com/icon.png', + }); - it('shows provided icon image when item.icon exists', () => { - renderComponent({ icon: 'https://example.com/icon.png' }); + render(); - expect(screen.getByLabelText('activity icon')).toBeOnTheScreen(); - }); + expect(screen.getByLabelText('activity icon')).toBeOnTheScreen(); + }); - it('falls back to Activity icon when no item.icon provided', () => { - renderComponent({ icon: undefined }); + it('navigates to activity detail when pressed', () => { + const item = createActivityItem(); - expect(screen.getByText('Icon:Activity')).toBeOnTheScreen(); - }); + render(); + const activityRow = screen.getByText('Buy'); + + fireEvent.press(activityRow); - it('calls onPress with item when pressed', () => { - const { item } = renderComponent({ icon: undefined }); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.ACTIVITY_DETAIL, + params: { activity: item }, + }); + }); + }); - // Press a child inside the touchable to trigger parent onPress - const pressTarget = screen.getByText('Icon:Activity'); - fireEvent.press(pressTarget); + describe('SELL activity', () => { + it('displays sell title with positive amount and negative percent', () => { + const item = createActivityItem({ + type: PredictActivityType.SELL, + percentChange: -3, + entry: { + type: 'sell', + timestamp: 0, + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 0, + amount: 1234.5, + price: 0.34, + }, + }); + + render(); + + expect(screen.getByText('Sell')).toBeOnTheScreen(); + expect(screen.getByText('+$1,234.50')).toBeOnTheScreen(); + expect(screen.getByText('-3%')).toBeOnTheScreen(); + }); + }); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { - screen: Routes.PREDICT.ACTIVITY_DETAIL, - params: { activity: item }, + describe('CLAIM activity', () => { + it('displays claim title without detail text', () => { + const item = createActivityItem({ + type: PredictActivityType.CLAIM, + detail: '$123.45 on Yes â€ĸ 34Âĸ', + entry: { + type: 'claimWinnings', + timestamp: 0, + amount: 1234.5, + }, + }); + + render(); + + expect(screen.getByText('Claim')).toBeOnTheScreen(); + expect(screen.queryByText('$123.45 on Yes â€ĸ 34Âĸ')).toBeNull(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx index 1911d2fe563..0852523d896 100644 --- a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx @@ -32,71 +32,9 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: (..._args: unknown[]) => ({}), - }), -})); - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - BoxFlexDirection: { Row: 'row' }, - BoxAlignItems: { Center: 'center' }, - BoxJustifyContent: { Between: 'between' }, -})); - -jest.mock('../../../../../component-library/components/Texts/Text', () => { - const ReactActual = jest.requireActual('react'); - const { Text: RNText } = jest.requireActual('react-native'); - return { - __esModule: true, - default: (props: React.ComponentProps) => - ReactActual.createElement(RNText, props, props.children), - TextVariant: { - HeadingMD: 'HeadingMD', - HeadingLG: 'HeadingLG', - BodyMD: 'BodyMD', - }, - TextColor: { - Default: 'Default', - Alternative: 'Alternative', - Success: 'Success', - Error: 'Error', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Icons/Icon', () => { - const ReactActual = jest.requireActual('react'); - const { Text: RNText } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ name }: { name: string }) => - ReactActual.createElement(RNText, null, `Icon:${name}`), - IconName: { ArrowLeft: 'ArrowLeft' }, - IconSize: { Md: 'Md' }, - }; -}); - -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ colors: { icon: { default: '#000' } } }), -})); - -jest.mock('react-native-safe-area-context', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return { - SafeAreaView: ( - props: React.ComponentProps & { children?: React.ReactNode }, - ) => ReactActual.createElement(View, props, props.children), - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), - }; -}); - const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); const mockCanGoBack = jest.fn(() => true); - const mockUseRoute = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -116,39 +54,29 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); -const baseBuyActivity: PredictActivityItem = { - id: '1', - type: PredictActivityType.BUY, - marketTitle: 'Market X', - detail: '', - amountUsd: 123.45, - outcome: 'Yes', - entry: { - type: 'buy', +const createActivityItem = ( + overrides?: Partial, +): PredictActivityItem => { + const baseEntry = { + type: 'buy' as const, timestamp: 0, marketId: 'm', outcomeId: 'o', outcomeTokenId: 0, amount: 123.45, price: 0.34, - }, -}; + }; -const renderWithActivity = (overrides?: Partial) => { - const activity: PredictActivityItem = { - ...baseBuyActivity, + return { + id: '1', + type: PredictActivityType.BUY, + marketTitle: 'Market X', + detail: '', + amountUsd: 123.45, + outcome: 'Yes', + entry: baseEntry, ...overrides, - entry: { - ...baseBuyActivity.entry, - ...(overrides?.entry ?? {}), - } as PredictActivityItem['entry'], }; - - mockUseRoute.mockReturnValue({ params: { activity } }); - - render(); - - return activity; }; describe('PredictActivityDetail', () => { @@ -156,127 +84,308 @@ describe('PredictActivityDetail', () => { jest.clearAllMocks(); }); - afterEach(() => { - jest.clearAllMocks(); - }); + describe('BUY activity', () => { + it('displays buy title and market information', () => { + const activity = createActivityItem({ + type: PredictActivityType.BUY, + priceImpactPercentage: 1.5, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); + + expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Date')).toBeOnTheScreen(); + expect(screen.getByText('Not available')).toBeOnTheScreen(); + expect(screen.getByText('Market')).toBeOnTheScreen(); + expect(screen.getByText('Market X')).toBeOnTheScreen(); + expect(screen.getByText('Outcome')).toBeOnTheScreen(); + expect(screen.getByText('Yes')).toBeOnTheScreen(); + }); - it('renders BUY details: header, market info, predicted amount, shares, price and price impact; no amount badge', () => { - const activity = renderWithActivity({ - type: PredictActivityType.BUY, - priceImpactPercentage: 1.5, + it('displays predicted amount with formatted value', () => { + const activity = createActivityItem({ + type: PredictActivityType.BUY, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + const buyEntry = activity.entry as Extract< + PredictActivityItem['entry'], + { type: 'buy' } + >; + const expectedAmount = formatCurrencyValue(buyEntry.amount, { + showSign: false, + }) as string; + + render(); + + expect(screen.getByText('Predicted amount')).toBeOnTheScreen(); + expect(screen.getByText(expectedAmount)).toBeOnTheScreen(); }); - expect(screen.getByText('Buy')).toBeOnTheScreen(); - - expect(screen.getByText('Date')).toBeOnTheScreen(); - expect(screen.getByText('Not available')).toBeOnTheScreen(); - expect(screen.getByText('Market')).toBeOnTheScreen(); - expect(screen.getByText(activity.marketTitle)).toBeOnTheScreen(); - expect(screen.getByText('Outcome')).toBeOnTheScreen(); - const outcomeBuy = activity.outcome as string; - expect(screen.getByText(outcomeBuy)).toBeOnTheScreen(); - - const buyEntry = activity.entry as Extract< - PredictActivityItem['entry'], - { type: 'buy' } - >; - const expectedPredictedAmount = formatCurrencyValue(buyEntry.amount, { - showSign: false, - }) as string; - const expectedShares = formatPositionSize(buyEntry.amount / buyEntry.price); - const expectedPricePerShare = formatPrice(buyEntry.price, { - minimumDecimals: buyEntry.price >= 1 ? 2 : 4, - maximumDecimals: buyEntry.price >= 1 ? 2 : 4, + it('displays shares bought with calculated value', () => { + const activity = createActivityItem({ + type: PredictActivityType.BUY, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + const buyEntry = activity.entry as Extract< + PredictActivityItem['entry'], + { type: 'buy' } + >; + const expectedShares = formatPositionSize( + buyEntry.amount / buyEntry.price, + ); + + render(); + + expect(screen.getByText('Shares bought')).toBeOnTheScreen(); + expect(screen.getByText(expectedShares)).toBeOnTheScreen(); }); - expect(screen.getByText('Predicted amount')).toBeOnTheScreen(); - expect(screen.getByText(expectedPredictedAmount)).toBeOnTheScreen(); + it('displays price per share with formatted value', () => { + const activity = createActivityItem({ + type: PredictActivityType.BUY, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + const buyEntry = activity.entry as Extract< + PredictActivityItem['entry'], + { type: 'buy' } + >; + const expectedPrice = formatPrice(buyEntry.price, { + minimumDecimals: buyEntry.price >= 1 ? 2 : 4, + maximumDecimals: buyEntry.price >= 1 ? 2 : 4, + }); + + render(); + + expect(screen.getByText('Price per share')).toBeOnTheScreen(); + expect(screen.getByText(expectedPrice)).toBeOnTheScreen(); + }); - expect(screen.getByText('Shares bought')).toBeOnTheScreen(); - expect(screen.getByText(expectedShares)).toBeOnTheScreen(); + it('displays price impact when provided', () => { + const activity = createActivityItem({ + type: PredictActivityType.BUY, + priceImpactPercentage: 1.5, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); - expect(screen.getByText('Price per share')).toBeOnTheScreen(); - expect(screen.getByText(expectedPricePerShare)).toBeOnTheScreen(); + render(); - expect(screen.getByText('Price impact')).toBeOnTheScreen(); - expect(screen.getByText('1.5%')).toBeOnTheScreen(); + expect(screen.getByText('Price impact')).toBeOnTheScreen(); + expect(screen.getByText('1.5%')).toBeOnTheScreen(); + }); + + it('hides USDC badge for buy activities', () => { + const activity = createActivityItem({ + type: PredictActivityType.BUY, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); - expect(screen.queryByLabelText('USDC')).toBeNull(); + expect(screen.queryByLabelText('USDC')).toBeNull(); + }); }); - it('renders SELL details with amount badge, shares sold, price per share and net pnl; excludes predicted amount and price impact', () => { - const activity = renderWithActivity({ - type: PredictActivityType.SELL, - amountUsd: 50, - netPnlUsd: -10, - entry: { - type: 'sell', - timestamp: 0, - marketId: 'm', - outcomeId: 'o', - outcomeTokenId: 0, - amount: 50, - price: 0.5, - }, + describe('SELL activity', () => { + it('displays sell title with USDC badge and amount', () => { + const activity = createActivityItem({ + type: PredictActivityType.SELL, + amountUsd: 50, + netPnlUsd: -10, + entry: { + type: 'sell', + timestamp: 0, + marketId: 'm', + outcomeId: 'o', + outcomeTokenId: 0, + amount: 50, + price: 0.5, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + const expectedAmount = formatCurrencyValue(activity.amountUsd) as string; + + render(); + + expect(screen.getByText('Sell')).toBeOnTheScreen(); + expect(screen.getByLabelText('USDC')).toBeOnTheScreen(); + expect(screen.getByText(expectedAmount)).toBeOnTheScreen(); + }); + + it('displays shares sold with price per share', () => { + const activity = createActivityItem({ + type: PredictActivityType.SELL, + amountUsd: 50, + netPnlUsd: -10, + entry: { + type: 'sell', + timestamp: 0, + marketId: 'm', + outcomeId: 'o', + outcomeTokenId: 0, + amount: 50, + price: 0.5, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + const sellEntry = activity.entry as Extract< + PredictActivityItem['entry'], + { type: 'sell' } + >; + const expectedShares = formatPositionSize( + sellEntry.amount / sellEntry.price, + ); + const expectedPrice = formatPrice(sellEntry.price, { + minimumDecimals: 4, + maximumDecimals: 4, + }); + + render(); + + expect(screen.getByText('Shares sold')).toBeOnTheScreen(); + expect(screen.getByText(expectedShares)).toBeOnTheScreen(); + expect(screen.getByText('Price per share')).toBeOnTheScreen(); + expect(screen.getByText(expectedPrice)).toBeOnTheScreen(); }); - expect(screen.getByText('Sell')).toBeOnTheScreen(); - expect(screen.getByLabelText('USDC')).toBeOnTheScreen(); - const amountSellText = formatCurrencyValue(activity.amountUsd) as string; - expect(screen.getByText(amountSellText)).toBeOnTheScreen(); - const sellEntry = activity.entry as Extract< - PredictActivityItem['entry'], - { type: 'sell' } - >; - const expectedShares = formatPositionSize( - sellEntry.amount / sellEntry.price, - ); - const expectedPricePerShare = formatPrice(sellEntry.price, { - minimumDecimals: 4, - maximumDecimals: 4, + it('displays net PnL for sell activity', () => { + const activity = createActivityItem({ + type: PredictActivityType.SELL, + amountUsd: 50, + netPnlUsd: -10, + entry: { + type: 'sell', + timestamp: 0, + marketId: 'm', + outcomeId: 'o', + outcomeTokenId: 0, + amount: 50, + price: 0.5, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); + + expect(screen.getByText('Net PnL')).toBeOnTheScreen(); + expect(screen.getByText('-$10.00')).toBeOnTheScreen(); + }); + + it('hides predicted amount and price impact for sell activities', () => { + const activity = createActivityItem({ + type: PredictActivityType.SELL, + amountUsd: 50, + entry: { + type: 'sell', + timestamp: 0, + marketId: 'm', + outcomeId: 'o', + outcomeTokenId: 0, + amount: 50, + price: 0.5, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); + + expect(screen.queryByText('Predicted amount')).toBeNull(); + expect(screen.queryByText('Price impact')).toBeNull(); }); - expect(screen.getByText('Shares sold')).toBeOnTheScreen(); - expect(screen.getByText(expectedShares)).toBeOnTheScreen(); - expect(screen.getByText('Price per share')).toBeOnTheScreen(); - expect(screen.getByText(expectedPricePerShare)).toBeOnTheScreen(); - expect(screen.getByText('Net PnL')).toBeOnTheScreen(); - expect(screen.getByText('-$10.00')).toBeOnTheScreen(); - expect(screen.queryByText('Predicted amount')).toBeNull(); - expect(screen.queryByText('Price impact')).toBeNull(); }); - it('renders CLAIM details: amount badge and pnl rows; omits market/outcome rows', () => { - renderWithActivity({ - type: PredictActivityType.CLAIM, - amountUsd: 200, - totalNetPnlUsd: 150, - netPnlUsd: 120, - entry: { - type: 'claimWinnings', - timestamp: 0, - amount: 200, - }, + describe('CLAIM activity', () => { + it('displays claim title with USDC badge and amount', () => { + const activity = createActivityItem({ + type: PredictActivityType.CLAIM, + amountUsd: 200, + totalNetPnlUsd: 150, + netPnlUsd: 120, + entry: { + type: 'claimWinnings', + timestamp: 0, + amount: 200, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); + + expect(screen.getByText('Claim')).toBeOnTheScreen(); + expect(screen.getByLabelText('USDC')).toBeOnTheScreen(); + expect(screen.getByText('$200.00')).toBeOnTheScreen(); + }); + + it('displays total net PnL with market-specific PnL', () => { + const activity = createActivityItem({ + type: PredictActivityType.CLAIM, + amountUsd: 200, + totalNetPnlUsd: 150, + netPnlUsd: 120, + entry: { + type: 'claimWinnings', + timestamp: 0, + amount: 200, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); + + expect(screen.getByText('Total net PnL')).toBeOnTheScreen(); + expect(screen.getByText('+$150.00')).toBeOnTheScreen(); + expect(screen.getByText('Market X')).toBeOnTheScreen(); + expect(screen.getByText('+$120.00')).toBeOnTheScreen(); }); - expect(screen.getByText('Claim')).toBeOnTheScreen(); - expect(screen.getByLabelText('USDC')).toBeOnTheScreen(); - expect(screen.getByText('$200.00')).toBeOnTheScreen(); - expect(screen.getByText('Total net PnL')).toBeOnTheScreen(); - expect(screen.getByText('+$150.00')).toBeOnTheScreen(); - expect(screen.getByText('Market X')).toBeOnTheScreen(); - expect(screen.getByText('+$120.00')).toBeOnTheScreen(); - expect(screen.queryByText('Market')).toBeNull(); - expect(screen.queryByText('Outcome')).toBeNull(); + it('hides market and outcome labels for claim activities', () => { + const activity = createActivityItem({ + type: PredictActivityType.CLAIM, + amountUsd: 200, + entry: { + type: 'claimWinnings', + timestamp: 0, + amount: 200, + }, + }); + mockUseRoute.mockReturnValue({ params: { activity } }); + + render(); + + expect(screen.queryByText('Market')).toBeNull(); + expect(screen.queryByText('Outcome')).toBeNull(); + }); }); - it('navigates back via goBack when possible, otherwise navigates to ROOT', () => { - renderWithActivity(); + describe('navigation', () => { + it('calls goBack when back button is pressed and navigation can go back', () => { + const activity = createActivityItem(); + mockUseRoute.mockReturnValue({ params: { activity } }); + mockCanGoBack.mockReturnValue(true); + + render(); + const backButton = screen.getByTestId( + 'predict-activity-details-back-button', + ); + + fireEvent.press(backButton); - mockCanGoBack.mockReturnValueOnce(true); - fireEvent.press(screen.getByText('Icon:ArrowLeft')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - mockCanGoBack.mockReturnValueOnce(false); - fireEvent.press(screen.getByText('Icon:ArrowLeft')); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('navigates to root when back button is pressed and cannot go back', () => { + const activity = createActivityItem(); + mockUseRoute.mockReturnValue({ params: { activity } }); + mockCanGoBack.mockReturnValue(false); + + render(); + const backButton = screen.getByTestId( + 'predict-activity-details-back-button', + ); + + fireEvent.press(backButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT); + }); }); }); diff --git a/app/components/UI/Predict/components/PredictAddFundsSheet/PredictAddFundsSheet.test.tsx b/app/components/UI/Predict/components/PredictAddFundsSheet/PredictAddFundsSheet.test.tsx index a855949637e..67b261786ac 100644 --- a/app/components/UI/Predict/components/PredictAddFundsSheet/PredictAddFundsSheet.test.tsx +++ b/app/components/UI/Predict/components/PredictAddFundsSheet/PredictAddFundsSheet.test.tsx @@ -1,18 +1,25 @@ import React, { useRef, useEffect } from 'react'; -import { render, fireEvent, act } from '@testing-library/react-native'; - -// Internal dependencies +import { + render, + fireEvent, + screen, + waitFor, +} from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import PredictAddFundsSheet, { PredictAddFundsSheetRef, } from './PredictAddFundsSheet'; import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; -import { strings } from '../../../../../../locales/i18n'; -// Mock dependencies jest.mock('../../hooks/usePredictDeposit'); jest.mock('../../hooks/usePredictActionGuard'); +jest.mock('@react-navigation/compat', () => ({ + withNavigation: (component: T): T => component, + withNavigationFocus: (component: T): T => component, +})); + jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const translations: Record = { @@ -24,177 +31,42 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: jest.fn(() => ({})), - }), -})); - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: jest.fn(), - goBack: jest.fn(), - }), - }; -}); +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); -jest.mock('@react-navigation/compat', () => ({ - withNavigation: jest.fn((component) => component), +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), })); -// Mock BottomSheet component -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheet/BottomSheet', - () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - - return ReactActual.forwardRef( - ( - props: { - children?: React.ReactNode; - onClose?: () => void; - }, - ref: React.Ref<{ - onCloseBottomSheet: (callback?: () => void) => void; - onOpenBottomSheet: (callback?: () => void) => void; - }>, - ) => { - ReactActual.useImperativeHandle(ref, () => ({ - onCloseBottomSheet: (callback?: () => void) => { - callback?.(); - }, - onOpenBottomSheet: (callback?: () => void) => { - callback?.(); - }, - })); - - return ReactActual.createElement( - View, - { testID: 'bottom-sheet' }, - props.children, - ); - }, - ); - }, -); - -// Mock BottomSheetHeader -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader', - () => { - const ReactActual = jest.requireActual('react'); - const { View, TouchableOpacity } = jest.requireActual('react-native'); - - return ({ - children, - onClose, - }: { - children?: React.ReactNode; - onClose?: () => void; - }) => - ReactActual.createElement( - View, - { testID: 'bottom-sheet-header' }, - onClose && - ReactActual.createElement( - TouchableOpacity, - { testID: 'header-close-button', onPress: onClose }, - null, - ), - children, - ); - }, -); - -// Mock BottomSheetFooter -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter', - () => { - const ReactActual = jest.requireActual('react'); - const { - View, - TouchableOpacity, - Text: RNText, - } = jest.requireActual('react-native'); - - return ({ - buttonPropsArray, - }: { - buttonPropsArray: { - variant: string; - label: string; - onPress: () => void; - }[]; - }) => - ReactActual.createElement( - View, - { testID: 'bottom-sheet-footer' }, - buttonPropsArray.map( - ( - buttonProps: { - variant: string; - label: string; - onPress: () => void; - }, - index: number, - ) => - ReactActual.createElement( - TouchableOpacity, - { - key: index, - onPress: buttonProps.onPress, - testID: `footer-button-${index}`, - }, - ReactActual.createElement(RNText, {}, buttonProps.label), - ), - ), - ); - }, -); - -// Mock Box and Text components -jest.mock('@metamask/design-system-react-native', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text: RNText } = jest.requireActual('react-native'); - - const MockText = ({ - children, - twClassName, - ...props - }: { - children?: React.ReactNode; - twClassName?: string; - }) => ReactActual.createElement(RNText, props, children); - - return { - Box: ({ - children, - twClassName, - ...props - }: { - children?: React.ReactNode; - twClassName?: string; - }) => ReactActual.createElement(View, props, children), - BoxAlignItems: { - Start: 'flex-start', - }, - BoxJustifyContent: { - Start: 'flex-start', - }, - Text: MockText, - TextVariant: { - HeadingMd: 'HeadingMd', - BodyMd: 'BodyMd', - }, - IconName: { - QrCode: 'QrCode', - }, - }; -}); +const TestComponent = ({ + onDismiss, + shouldOpen = false, +}: { + onDismiss?: () => void; + shouldOpen?: boolean; +}) => { + const ref = useRef(null); + + useEffect(() => { + if (shouldOpen) { + ref.current?.onOpenBottomSheet(); + } + }, [shouldOpen]); + + return ( + + + + ); +}; describe('PredictAddFundsSheet', () => { const mockOnDismiss = jest.fn(); @@ -203,6 +75,7 @@ describe('PredictAddFundsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + (usePredictDeposit as jest.Mock).mockReturnValue({ deposit: mockDeposit, }); @@ -211,173 +84,49 @@ describe('PredictAddFundsSheet', () => { }); }); - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('visibility behavior', () => { - it('does not render when not visible', () => { - const { queryByTestId } = render( - , - ); + describe('visibility', () => { + it('hides bottom sheet on initial render', () => { + render(); - expect(queryByTestId('bottom-sheet')).toBeNull(); + expect(screen.queryByText('Add funds')).toBeNull(); }); - it('renders when opened via ref', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - expect(getByTestId('bottom-sheet')).toBeOnTheScreen(); - }); - }); + it('displays bottom sheet when opened via ref', async () => { + render(); - describe('ref methods', () => { - it('opens bottom sheet when onOpenBottomSheet is called', () => { - const TestComponent = () => { - const ref = useRef(null); - - return ( - <> - - - ); - }; - - const { queryByTestId } = render(); - - expect(queryByTestId('bottom-sheet')).toBeNull(); - }); - - it('opens bottom sheet only once when already visible', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - expect(getByTestId('bottom-sheet')).toBeOnTheScreen(); - }); - - it('closes bottom sheet when onCloseBottomSheet is called', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - - setTimeout(() => { - act(() => { - ref.current?.onCloseBottomSheet(); - }); - }, 100); - }, []); - - return ; - }; - - render(); - - // Sheet closes after timeout + await waitFor(() => { + expect(screen.getAllByText('Add funds').length).toBeGreaterThan(0); + }); + expect( + screen.getByText( + /You'll need to add funds to your Predictions account/, + ), + ).toBeOnTheScreen(); }); }); - describe('component structure', () => { - it('renders bottom sheet header when visible', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - expect(getByTestId('bottom-sheet-header')).toBeOnTheScreen(); - }); - - it('renders bottom sheet footer when visible', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - expect(getByTestId('bottom-sheet-footer')).toBeOnTheScreen(); - }); - - it('renders header close button', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; + describe('content', () => { + it('displays title and description when opened', async () => { + render(); - const { getByTestId } = render(); - - expect(getByTestId('header-close-button')).toBeOnTheScreen(); + await waitFor(() => { + expect(screen.getAllByText('Add funds').length).toBe(2); + }); + expect( + screen.getByText( + /You'll need to add funds to your Predictions account to get started/, + ), + ).toBeOnTheScreen(); }); }); - describe('user interactions', () => { - it('calls executeGuardedAction when add funds button is pressed', async () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - const addFundsButton = getByTestId('footer-button-0'); + describe('add funds interaction', () => { + it('calls executeGuardedAction with deposit function when add funds button is pressed', async () => { + render(); + const addFundsButton = await waitFor(() => + screen.getByRole('button', { name: /add funds/i }), + ); fireEvent.press(addFundsButton); expect(mockExecuteGuardedAction).toHaveBeenCalledTimes(1); @@ -387,166 +136,23 @@ describe('PredictAddFundsSheet', () => { ); }); - it('calls deposit function through executeGuardedAction', async () => { + it('calls deposit when executeGuardedAction executes callback', async () => { mockExecuteGuardedAction.mockImplementation((fn: () => void) => fn()); - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - const addFundsButton = getByTestId('footer-button-0'); + render(); + const addFundsButton = await waitFor(() => + screen.getByRole('button', { name: /add funds/i }), + ); fireEvent.press(addFundsButton); expect(mockDeposit).toHaveBeenCalledTimes(1); }); - - it('triggers close handler when header close button is pressed', async () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - const closeButton = getByTestId('header-close-button'); - - fireEvent.press(closeButton); - - // The close button was successfully pressed - expect(closeButton).toBeTruthy(); - }); }); describe('optional callbacks', () => { - it('does not crash when onDismiss is not provided', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - expect(() => render()).not.toThrow(); - }); - }); - - describe('hook integration', () => { - it('calls usePredictDeposit hook', () => { - render(); - - expect(usePredictDeposit).toHaveBeenCalled(); - }); - - it('calls usePredictActionGuard with correct providerId', () => { - render(); - - expect(usePredictActionGuard).toHaveBeenCalledWith({ - providerId: 'polymarket', - navigation: expect.any(Object), - }); - }); - - it('uses deposit from usePredictDeposit hook', async () => { - const customDeposit = jest.fn(); - mockExecuteGuardedAction.mockImplementation((fn: () => void) => fn()); - - (usePredictDeposit as jest.Mock).mockReturnValue({ - deposit: customDeposit, - }); - - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - const addFundsButton = getByTestId('footer-button-0'); - - fireEvent.press(addFundsButton); - - expect(customDeposit).toHaveBeenCalledTimes(1); - }); - - it('uses executeGuardedAction from usePredictActionGuard hook', async () => { - const customExecuteGuardedAction = jest.fn(); - - (usePredictActionGuard as jest.Mock).mockReturnValue({ - executeGuardedAction: customExecuteGuardedAction, - }); - - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - const { getByTestId } = render(); - - const addFundsButton = getByTestId('footer-button-0'); - - fireEvent.press(addFundsButton); - - expect(customExecuteGuardedAction).toHaveBeenCalledTimes(1); - }); - }); - - describe('localization', () => { - it('calls strings function with correct keys', () => { - const TestComponent = () => { - const ref = useRef(null); - - useEffect(() => { - act(() => { - ref.current?.onOpenBottomSheet(); - }); - }, []); - - return ; - }; - - render(); - - expect(strings).toHaveBeenCalledWith('predict.add_funds_sheet.title'); - expect(strings).toHaveBeenCalledWith( - 'predict.add_funds_sheet.description', - ); + it('renders without crashing when onDismiss is not provided', () => { + expect(() => render()).not.toThrow(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx b/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx index 2581d7d461d..fbea5f2f5a6 100644 --- a/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx @@ -7,47 +7,15 @@ import { } from '@react-navigation/native'; import PredictMarketList from './PredictMarketList'; -// Mock dependencies jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), useFocusEffect: jest.fn(), })); -jest.mock('../../../../../component-library/hooks', () => ({ - useStyles: jest.fn(() => ({ - styles: { - wrapper: {}, - tabView: {}, - tabContent: {}, - }, - })), -})); - -jest.mock('../../../../../component-library/components/Texts/Text', () => { - const { Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: Text, - TextVariant: { - HeadingLG: 'HeadingLG', - BodyMD: 'BodyMD', - BodySM: 'BodySM', - }, - TextColor: { - Default: 'Default', - Primary: 'Primary', - Alternative: 'Alternative', - Muted: 'Muted', - Success: 'Success', - Error: 'Error', - }, - }; -}); - jest.mock('../../../../Base/TabBar', () => { const { View } = jest.requireActual('react-native'); - return function MockTabBar({ textStyle }: { textStyle: object }) { - return ; + return function MockTabBar() { + return ; }; }); @@ -62,170 +30,6 @@ jest.mock('../../components/MarketListContent', () => { }; }); -jest.mock('../../components/PredictBalance/PredictBalance', () => { - const { View, Text } = jest.requireActual('react-native'); - return function MockPredictBalance() { - return ( - - Balance: $100.00 - - ); - }; -}); - -jest.mock('../../components/SearchBox', () => { - const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); - return function MockSearchBox({ - isVisible, - onCancel, - onSearch, - }: { - isVisible: boolean; - onCancel: () => void; - onSearch: (query: string) => void; - }) { - return ( - - Search Box Visible: {String(isVisible)} - - Cancel - - onSearch('test query')} - > - Search - - - ); - }; -}); - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: ({ - children, - testID, - ...props - }: { - children?: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => { - const { View } = jest.requireActual('react-native'); - return ( - - {children} - - ); - }, - BoxFlexDirection: { - Row: 'row', - }, - BoxAlignItems: { - Center: 'center', - }, - BoxJustifyContent: { - Between: 'space-between', - }, -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: jest.fn(() => ({ - style: jest.fn((...args) => args.join(' ')), - })), -})); - -jest.mock('../../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function MockIcon({ - name, - testID, - }: { - name: string; - testID?: string; - }) { - return ; - }, - IconName: { - Search: 'Search', - AddSquare: 'AddSquare', - }, - IconSize: { - Lg: 'Lg', - Md: 'Md', - }, - IconColor: { - Default: 'Default', - Primary: 'Primary', - Alternative: 'Alternative', - Muted: 'Muted', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Avatars/Avatar', () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function MockAvatar({ - variant, - testID, - }: { - variant: string; - testID?: string; - }) { - return ; - }, - AvatarVariant: { - Icon: 'Icon', - }, - AvatarSize: { - Md: 'Md', - Sm: 'Sm', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Buttons/Button', () => { - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function MockButton({ - onPress, - children, - label, - testID, - }: { - onPress?: () => void; - children?: React.ReactNode; - label?: string; - testID?: string; - }) { - return ( - - {label || children} - - ); - }, - ButtonVariants: { - Link: 'Link', - Primary: 'Primary', - Secondary: 'Secondary', - }, - ButtonSize: { - Md: 'Md', - Sm: 'Sm', - Lg: 'Lg', - }, - ButtonWidthTypes: { - Auto: 'Auto', - Full: 'Full', - }, - }; -}); - jest.mock('../../hooks/usePredictBalance', () => ({ usePredictBalance: jest.fn(() => ({ balance: 100, @@ -237,10 +41,9 @@ jest.mock('../../hooks/usePredictBalance', () => ({ })), })); -jest.mock('../../utils/format', () => ({ - formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), - formatVolume: jest.fn((value: number) => value.toLocaleString()), -})); +let mockOnChangeTab: + | ((changeInfo: { i: number; ref: unknown; from?: number }) => void) + | undefined; jest.mock('@tommasini/react-native-scrollable-tab-view', () => { const { View } = jest.requireActual('react-native'); @@ -250,11 +53,18 @@ jest.mock('@tommasini/react-native-scrollable-tab-view', () => { children, renderTabBar, style, + onChangeTab, }: { children?: React.ReactNode; renderTabBar?: false | (() => React.ReactNode); style?: object; + onChangeTab?: (changeInfo: { + i: number; + ref: unknown; + from?: number; + }) => void; }) { + mockOnChangeTab = onChangeTab; return ( {renderTabBar && typeof renderTabBar === 'function' && renderTabBar()} @@ -265,10 +75,6 @@ jest.mock('@tommasini/react-native-scrollable-tab-view', () => { }; }); -jest.mock('../../../Navbar', () => ({ - getNavigationOptionsTitle: jest.fn(), -})); - jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const translations: Record = { @@ -282,30 +88,6 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -jest.mock('../../../../../../e2e/selectors/Predict/Predict.selectors', () => ({ - PredictMarketListSelectorsIDs: { - CONTAINER: 'predict-market-list-container', - TRENDING_TAB: 'predict-market-list-trending-tab', - NEW_TAB: 'predict-market-list-new-tab', - SPORTS_TAB: 'predict-market-list-sports-tab', - CRYPTO_TAB: 'predict-market-list-crypto-tab', - POLITICS_TAB: 'predict-market-list-politics-tab', - }, -})); - -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - background: { - default: '#ffffff', - }, - text: { - default: '#121314', - }, - }, - })), -})); - describe('PredictMarketList', () => { const mockNavigation = { canGoBack: jest.fn(), @@ -327,36 +109,45 @@ describe('PredictMarketList', () => { typeof useNavigation >; + const createMockSharedValue = (initialValue: number) => ({ + value: initialValue, + get: jest.fn(() => initialValue), + set: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + }); + + const createMockScrollCoordinator = (overrides = {}) => ({ + balanceCardOffset: createMockSharedValue(0), + balanceCardHeight: createMockSharedValue(0), + setBalanceCardHeight: jest.fn(), + setCurrentCategory: jest.fn(), + getTabScrollPosition: jest.fn(() => 0), + setTabScrollPosition: jest.fn(), + getScrollHandler: jest.fn(), + isBalanceCardHidden: jest.fn(() => false), + updateBalanceCardHiddenState: jest.fn(), + ...overrides, + }); + beforeEach(() => { jest.clearAllMocks(); - + mockOnChangeTab = undefined; mockUseNavigation.mockReturnValue( mockNavigation as unknown as NavigationProp, ); }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); - describe('Component Rendering', () => { - it('renders the scrollable tab view when search is not visible', () => { + describe('default view (no search)', () => { + it('displays scrollable tab view with all market categories', () => { render(); expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen(); - }); - - it('renders the tab bar when search is not visible', () => { - render(); - - expect(screen.getByTestId('tab-bar')).toBeOnTheScreen(); - }); - }); - - describe('Tab Content', () => { - it('renders all market list content components for each category', () => { - render(); - expect( screen.getByTestId('market-list-content-trending'), ).toBeOnTheScreen(); @@ -372,7 +163,7 @@ describe('PredictMarketList', () => { ).toBeOnTheScreen(); }); - it('displays correct category labels', () => { + it('displays correct category labels for all tabs', () => { render(); expect(screen.getByTestId('category-trending')).toHaveTextContent( @@ -393,61 +184,117 @@ describe('PredictMarketList', () => { }); }); - describe('Component Structure', () => { - it('renders with correct component hierarchy when search is not visible', () => { - render(); + describe('search mode', () => { + it('hides tab view when search is active without query', () => { + render(); - expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen(); - expect(screen.getByTestId('tab-bar')).toBeOnTheScreen(); + expect(screen.queryByTestId('scrollable-tab-view')).not.toBeOnTheScreen(); }); - it('renders all required market categories', () => { - render(); + it('displays search results when query is provided', () => { + render(); - const categories = ['trending', 'new', 'sports', 'crypto', 'politics']; - categories.forEach((category) => { - expect( - screen.getByTestId(`market-list-content-${category}`), - ).toBeOnTheScreen(); - }); + expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen(); + expect( + screen.getByTestId('market-list-content-trending'), + ).toBeOnTheScreen(); }); }); - describe('Search Functionality', () => { - it('hides main tab view when search is visible without query', () => { - const { queryByTestId } = render( - , + describe('callbacks', () => { + it('calls onTabChange when tab changes', () => { + const mockOnTabChangeCallback = jest.fn(); + + render( + , ); - // Main tab content should not be visible when search is active - expect(queryByTestId('scrollable-tab-view')).not.toBeOnTheScreen(); + mockOnChangeTab?.({ i: 2, ref: null }); + + expect(mockOnTabChangeCallback).toHaveBeenCalledWith('sports'); }); - it('shows search results when search query is provided', () => { - render(); + it('updates scrollCoordinator when tab changes', () => { + const mockSetCurrentCategory = jest.fn(); + const mockScrollCoordinator = createMockScrollCoordinator({ + setCurrentCategory: mockSetCurrentCategory, + }); - // Search results should be displayed - expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen(); - expect( - screen.getByTestId('market-list-content-trending'), - ).toBeOnTheScreen(); + render( + , + ); + + mockOnChangeTab?.({ i: 1, ref: null }); + + expect(mockSetCurrentCategory).toHaveBeenCalledWith('new'); }); - it('shows main tab view when search is not visible', () => { - render(); + it('handles tab change with both scrollCoordinator and onTabChange', () => { + const mockOnTabChangeCallback = jest.fn(); + const mockSetCurrentCategory = jest.fn(); + const mockScrollCoordinator = createMockScrollCoordinator({ + setCurrentCategory: mockSetCurrentCategory, + }); - // Main tab content should be visible - expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen(); - expect(screen.getByTestId('tab-bar')).toBeOnTheScreen(); + render( + , + ); + + mockOnChangeTab?.({ i: 4, ref: null }); + + expect(mockSetCurrentCategory).toHaveBeenCalledWith('politics'); + expect(mockOnTabChangeCallback).toHaveBeenCalledWith('politics'); + }); + + it('does not call callbacks when tab index is out of bounds', () => { + const mockOnTabChangeCallback = jest.fn(); + const mockSetCurrentCategory = jest.fn(); + const mockScrollCoordinator = createMockScrollCoordinator({ + setCurrentCategory: mockSetCurrentCategory, + }); + + render( + , + ); + + mockOnChangeTab?.({ i: 10, ref: null }); + + expect(mockSetCurrentCategory).not.toHaveBeenCalled(); + expect(mockOnTabChangeCallback).not.toHaveBeenCalled(); }); + }); - it('hides tab bar when search is active with query', () => { - const { queryByTestId } = render( - , + describe('with scrollCoordinator', () => { + it('renders with scrollCoordinator prop', () => { + const mockScrollCoordinator = createMockScrollCoordinator(); + + render( + , ); - // Tab bar should not be visible during search - expect(queryByTestId('tab-bar')).not.toBeOnTheScreen(); + expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictNewButton/PredictNewButton.test.tsx b/app/components/UI/Predict/components/PredictNewButton/PredictNewButton.test.tsx index 21ff711c66a..68ac2ce376c 100644 --- a/app/components/UI/Predict/components/PredictNewButton/PredictNewButton.test.tsx +++ b/app/components/UI/Predict/components/PredictNewButton/PredictNewButton.test.tsx @@ -5,99 +5,11 @@ import PredictNewButton from './PredictNewButton'; import Routes from '../../../../../constants/navigation/Routes'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -// Mock dependencies jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: jest.fn(), })); -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: jest.fn(() => ({ - style: jest.fn((...args) => args.join(' ')), - })), -})); - -jest.mock('@metamask/design-system-react-native', () => { - const { View, Text: RNText } = jest.requireActual('react-native'); - return { - Box: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - Text: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - TextVariant: { - BodyMd: 'BodyMd', - }, - BoxFlexDirection: { - Row: 'row', - }, - BoxAlignItems: { - Center: 'center', - }, - BoxJustifyContent: { - Center: 'center', - }, - FontWeight: { - Medium: 'medium', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - name, - size, - color, - testID, - ...props - }: { - name: string; - size: string; - color: string; - testID?: string; - [key: string]: unknown; - }) => ( - - {name} - - ), - IconName: { - Add: 'add', - }, - IconSize: { - Md: 'md', - }, - IconColor: { - Default: 'default', - }, - }; -}); - -// Mock strings jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const mockStrings: Record = { @@ -132,166 +44,53 @@ describe('PredictNewButton', () => { }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); - describe('Component Rendering', () => { - it('renders the button with correct text', () => { + describe('rendering', () => { + it('displays button with correct testID', () => { renderWithProvider(); - expect(screen.getByText('New prediction')).toBeOnTheScreen(); + expect(screen.getByTestId('predict-new-button')).toBeOnTheScreen(); }); - it('renders the add icon', () => { - renderWithProvider(); - - expect(screen.getByTestId('icon')).toBeOnTheScreen(); - }); + it('uses correct localization key', () => { + const { strings } = jest.requireMock('../../../../../../locales/i18n'); - it('renders all required elements', () => { renderWithProvider(); - expect(screen.getByText('New prediction')).toBeOnTheScreen(); - expect(screen.getByTestId('icon')).toBeOnTheScreen(); + expect(strings).toHaveBeenCalledWith('predict.tab.new_prediction'); }); }); - describe('Navigation Interaction', () => { - it('navigates to market list when button is pressed', () => { + describe('navigation', () => { + it('navigates to market list when pressed', () => { renderWithProvider(); - const button = screen.getByText('New prediction'); + const button = screen.getByTestId('predict-new-button'); fireEvent.press(button); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); expect(mockNavigation.navigate).toHaveBeenCalledWith( Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, params: { - entryPoint: expect.any(String), + entryPoint: 'homepage_new_prediction', }, }, ); }); - it('calls navigation only once per press', () => { + it('navigates on each press when pressed multiple times', () => { renderWithProvider(); - const button = screen.getByText('New prediction'); - - fireEvent.press(button); - - expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); - }); - - it('handles multiple presses correctly', () => { - renderWithProvider(); - const button = screen.getByText('New prediction'); + const button = screen.getByTestId('predict-new-button'); fireEvent.press(button); fireEvent.press(button); fireEvent.press(button); expect(mockNavigation.navigate).toHaveBeenCalledTimes(3); - expect(mockNavigation.navigate).toHaveBeenCalledWith( - Routes.PREDICT.ROOT, - { - screen: Routes.PREDICT.MARKET_LIST, - params: { - entryPoint: expect.any(String), - }, - }, - ); - }); - }); - - describe('Icon Display', () => { - it('displays the correct add icon', () => { - renderWithProvider(); - - const icon = screen.getByTestId('icon'); - expect(icon).toBeOnTheScreen(); - }); - - it('renders icon with correct properties', () => { - renderWithProvider(); - - const icon = screen.getByTestId('icon'); - expect(icon).toBeOnTheScreen(); - }); - }); - - describe('Text Content', () => { - it('displays the localized text correctly', () => { - renderWithProvider(); - - expect(screen.getByText('New prediction')).toBeOnTheScreen(); - }); - - it('uses the correct string key for localization', () => { - // Import the mocked strings function - const { strings } = jest.requireMock('../../../../../../locales/i18n'); - renderWithProvider(); - - screen.getByText('New prediction'); - - expect(strings).toHaveBeenCalledWith('predict.tab.new_prediction'); - }); - }); - - describe('Component Structure', () => { - it('renders without crashing', () => { - renderWithProvider(); - - expect(screen.getByText('New prediction')).toBeOnTheScreen(); - }); - - it('maintains consistent structure across renders', () => { - const { rerender } = renderWithProvider(); - - expect(screen.getByText('New prediction')).toBeOnTheScreen(); - expect(screen.getByTestId('icon')).toBeOnTheScreen(); - - rerender(); - - expect(screen.getByText('New prediction')).toBeOnTheScreen(); - expect(screen.getByTestId('icon')).toBeOnTheScreen(); - }); - }); - - describe('Accessibility', () => { - it('is pressable and accessible', () => { - renderWithProvider(); - const button = screen.getByText('New prediction'); - - fireEvent.press(button); - - expect(mockNavigation.navigate).toHaveBeenCalled(); - }); - }); - - describe('Edge Cases', () => { - it('handles navigation errors gracefully', () => { - const errorNavigation = { - ...mockNavigation, - navigate: jest.fn(() => { - throw new Error('Navigation failed'); - }), - }; - mockUseNavigation.mockReturnValue( - errorNavigation as unknown as ReturnType, - ); - renderWithProvider(); - const button = screen.getByText('New prediction'); - - expect(() => fireEvent.press(button)).toThrow('Navigation failed'); - }); - - it('handles missing navigation context', () => { - mockUseNavigation.mockReturnValue( - undefined as unknown as ReturnType, - ); - - expect(() => renderWithProvider()).not.toThrow(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictOffline/PredictOffline.test.tsx b/app/components/UI/Predict/components/PredictOffline/PredictOffline.test.tsx index 101ae675014..cfa861c44ff 100644 --- a/app/components/UI/Predict/components/PredictOffline/PredictOffline.test.tsx +++ b/app/components/UI/Predict/components/PredictOffline/PredictOffline.test.tsx @@ -3,125 +3,20 @@ import { screen, fireEvent } from '@testing-library/react-native'; import PredictOffline from './PredictOffline'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -// Mock dependencies -jest.mock('@metamask/design-system-react-native', () => { - const { View, Text } = jest.requireActual('react-native'); - return { - Box: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - Text: ({ - children, - variant, - ...props - }: { - children: React.ReactNode; - variant?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - TextVariant: { - HeadingMd: 'heading-md', - BodyMd: 'body-md', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - name, - size, - color, - testID, - ...props - }: { - name: string; - size: string; - color: string; - testID?: string; - [key: string]: unknown; - }) => ( - - {name} - - ), - IconName: { - Warning: 'warning', - }, - IconSize: { - XXL: 'xxl', - }, - IconColor: { - Error: 'error', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Buttons/Button', () => { - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - onPress, - label, - testID, - ...props - }: { - onPress: () => void; - label: string; - testID?: string; - [key: string]: unknown; - }) => ( - - {label} - - ), - ButtonSize: { - Lg: 'lg', - }, - ButtonVariants: { - Primary: 'primary', - }, - }; -}); +describe('PredictOffline', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); -jest.mock('../../../../../component-library/hooks', () => ({ - useStyles: jest.fn(() => ({ - styles: { - errorState: {}, - errorStateIcon: {}, - errorStateTitle: {}, - errorStateDescription: {}, - errorStateButton: {}, - }, - })), -})); + afterEach(() => { + jest.resetAllMocks(); + }); -describe('PredictOffline', () => { - describe('Component Rendering', () => { - it('renders the error state with default message', () => { + describe('rendering', () => { + it('displays error message with title and description', () => { renderWithProvider(); + expect(screen.getByTestId('predict-error-state')).toBeOnTheScreen(); expect( screen.getByText('Unable to connect to predictions'), ).toBeOnTheScreen(); @@ -130,44 +25,17 @@ describe('PredictOffline', () => { 'Prediction markets are temporarily offline. Please check you have a stable connection and try again.', ), ).toBeOnTheScreen(); - expect(screen.getByTestId('icon')).toBeOnTheScreen(); }); - it('renders with custom test ID', () => { + it('uses custom testID when provided', () => { renderWithProvider(); expect(screen.getByTestId('custom-error-state')).toBeOnTheScreen(); }); - - it('renders with default test ID when not provided', () => { - renderWithProvider(); - - expect(screen.getByTestId('predict-error-state')).toBeOnTheScreen(); - }); - }); - - describe('Message Display', () => { - it('displays error description', () => { - renderWithProvider(); - - expect( - screen.getByText( - 'Prediction markets are temporarily offline. Please check you have a stable connection and try again.', - ), - ).toBeOnTheScreen(); - }); - - it('displays error title', () => { - renderWithProvider(); - - expect( - screen.getByText('Unable to connect to predictions'), - ).toBeOnTheScreen(); - }); }); - describe('Retry Button', () => { - it('renders retry button when onRetry callback is provided', () => { + describe('retry button', () => { + it('displays retry button when onRetry callback is provided', () => { const onRetry = jest.fn(); renderWithProvider(); @@ -180,56 +48,15 @@ describe('PredictOffline', () => { renderWithProvider(); - const retryButton = screen.getByText('Retry'); - fireEvent.press(retryButton); + fireEvent.press(screen.getByText('Retry')); expect(onRetry).toHaveBeenCalledTimes(1); }); - it('does not render retry button when onRetry callback is not provided', () => { + it('hides retry button when onRetry is not provided', () => { renderWithProvider(); expect(screen.queryByText('Retry')).not.toBeOnTheScreen(); }); }); - - describe('Icon Display', () => { - it('displays warning icon', () => { - renderWithProvider(); - - const icon = screen.getByTestId('icon'); - - expect(icon).toBeOnTheScreen(); - }); - }); - - describe('Edge Cases', () => { - it('renders without retry button when onRetry is undefined', () => { - renderWithProvider(); - - expect(screen.queryByText('Retry')).not.toBeOnTheScreen(); - }); - }); - - describe('Integration', () => { - it('renders all elements together with retry callback', () => { - const onRetry = jest.fn(); - - renderWithProvider( - , - ); - - expect(screen.getByTestId('network-error')).toBeOnTheScreen(); - expect( - screen.getByText('Unable to connect to predictions'), - ).toBeOnTheScreen(); - expect( - screen.getByText( - 'Prediction markets are temporarily offline. Please check you have a stable connection and try again.', - ), - ).toBeOnTheScreen(); - expect(screen.getByText('Retry')).toBeOnTheScreen(); - expect(screen.getByTestId('icon')).toBeOnTheScreen(); - }); - }); }); diff --git a/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.test.tsx b/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.test.tsx index c20c2d069b1..5dbec5b01e8 100644 --- a/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.test.tsx @@ -5,125 +5,11 @@ import PredictPositionEmpty from './PredictPositionEmpty'; import Routes from '../../../../../constants/navigation/Routes'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -// Mock dependencies jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: jest.fn(), })); -jest.mock('@metamask/design-system-react-native', () => { - const { View, Text } = jest.requireActual('react-native'); - return { - Box: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - Text: ({ - children, - variant, - ...props - }: { - children: React.ReactNode; - variant?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - TextVariant: { - HeadingMd: 'heading-md', - BodyMd: 'body-md', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - name, - size, - color, - testID, - ...props - }: { - name: string; - size: string; - color: string; - testID?: string; - [key: string]: unknown; - }) => ( - - {name} - - ), - IconName: { - Details: 'details', - }, - IconSize: { - XXL: 'xxl', - }, - IconColor: { - Muted: 'muted', - }, - }; -}); - -jest.mock('../../../../../component-library/components/Buttons/Button', () => { - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - onPress, - label, - testID, - ...props - }: { - onPress: () => void; - label: string; - testID?: string; - [key: string]: unknown; - }) => ( - - {label} - - ), - ButtonSize: { - Lg: 'lg', - }, - ButtonVariants: { - Primary: 'primary', - }, - }; -}); - -jest.mock('../../../../../component-library/hooks', () => ({ - useStyles: jest.fn(() => ({ - styles: { - emptyState: {}, - emptyStateIcon: {}, - emptyStateTitle: {}, - emptyStateDescription: {}, - exploreMarketsButton: {}, - }, - })), -})); - describe('PredictPositionEmpty', () => { const mockNavigation = { navigate: jest.fn(), @@ -149,11 +35,11 @@ describe('PredictPositionEmpty', () => { }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); - describe('Component Rendering', () => { - it('renders the empty state with all required elements', () => { + describe('rendering', () => { + it('displays empty state message and browse button', () => { renderWithProvider(); expect( @@ -162,58 +48,25 @@ describe('PredictPositionEmpty', () => { ), ).toBeOnTheScreen(); expect(screen.getByText('Browse markets')).toBeOnTheScreen(); - expect(screen.getByTestId('icon')).toBeOnTheScreen(); - }); - - it('renders the browse markets button', () => { - renderWithProvider(); - - const browseButton = screen.getByText('Browse markets'); - expect(browseButton).toBeOnTheScreen(); }); }); - describe('Navigation Interaction', () => { + describe('navigation', () => { it('navigates to market list when browse button is pressed', () => { renderWithProvider(); - const browseButton = screen.getByText('Browse markets'); - fireEvent.press(browseButton); + fireEvent.press(screen.getByText('Browse markets')); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); expect(mockNavigation.navigate).toHaveBeenCalledWith( Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, params: { - entryPoint: expect.any(String), + entryPoint: 'homepage_positions', }, }, ); }); }); - - describe('Content Display', () => { - it('displays the correct empty state description', () => { - renderWithProvider(); - - expect( - screen.getByText( - 'Your predictions will appear here, showing your stake and market movement.', - ), - ).toBeOnTheScreen(); - }); - - it('displays the correct button text', () => { - renderWithProvider(); - - expect(screen.getByText('Browse markets')).toBeOnTheScreen(); - }); - - it('displays the sparkle icon', () => { - renderWithProvider(); - - const icon = screen.getByTestId('icon'); - expect(icon).toBeOnTheScreen(); - }); - }); }); diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx index 6ee71454bad..8ea753b5a80 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx @@ -6,7 +6,20 @@ import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL'; import { PredictPosition, PredictPositionStatus } from '../../types'; import MarketsWonCard from './PredictPositionsHeader'; -// Mock Engine with AccountTreeController - MUST BE FIRST +// Mock account utilities +jest.mock('../../utils/accounts', () => ({ + getEvmAccountFromSelectedAccountGroup: jest.fn(() => ({ + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + name: 'Test Account', + metadata: { + lastSelected: 0, + }, + })), +})); + +// Mock Engine with AccountTreeController jest.mock('../../../../../core/Engine', () => ({ context: { AccountTreeController: { @@ -25,205 +38,16 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); -// Mock dependencies -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: jest.fn(() => ({ - style: jest.fn((...args) => args.join(' ')), - })), -})); - -jest.mock('@metamask/design-system-react-native', () => { - const { - View, - Text: RNText, - TouchableOpacity, - } = jest.requireActual('react-native'); - return { - Box: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - Text: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - Button: ({ - children, - onPress, - testID, - ...props - }: { - children: React.ReactNode; - onPress: () => void; - testID?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - TextVariant: { - BodyMd: 'BodyMd', - BodySm: 'BodySm', - }, - BoxFlexDirection: { - Row: 'row', - }, - BoxAlignItems: { - Center: 'center', - }, - BoxJustifyContent: { - Between: 'space-between', - Center: 'center', - }, - ButtonVariant: { - Secondary: 'secondary', - }, - ButtonSize: { - Lg: 'lg', - Md: 'md', - Sm: 'sm', - }, - TextColor: { - Primary: 'primary', - Secondary: 'secondary', - PrimaryInverse: 'primary-inverse', - Alternative: 'alternative', - Muted: 'muted', - Success: 'success', - Error: 'error', - Warning: 'warning', - Info: 'info', - }, - IconColor: { - Alternative: '#8A8A8A', - }, - }; -}); - -jest.mock( - '../../../../../component-library/components-temp/Buttons/ButtonHero', - () => { - const { TouchableOpacity } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - onPress, - testID, - children, - ...props - }: { - onPress?: () => void; - testID?: string; - children?: React.ReactNode; - [key: string]: unknown; - }) => ( - - {children} - - ), - }; - }, -); - -jest.mock('../../../../../component-library/components/Icons/Icon', () => { - const { View, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - name, - testID, - ...props - }: { - name: string; - testID?: string; - [key: string]: unknown; - }) => ( - - {name} - - ), - IconSize: { - Sm: 'sm', - }, - IconName: { - ArrowRight: 'ArrowRight', - }, - IconColor: { - Alternative: '#8A8A8A', - }, - }; -}); - -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ testID, ...props }: { testID?: string }) => ( - - ), - }; - }, -); - -// Mock Image component and ActivityIndicator -jest.mock('react-native', () => { - const RN = jest.requireActual('react-native'); - const { Image: RNImage, View } = RN; - return { - ...RN, - Image: ({ - source, - testID, - ...props - }: { - source: { uri: string }; - testID?: string; - [key: string]: unknown; - }) => , - ActivityIndicator: ({ - testID, - ...props - }: { - testID?: string; - [key: string]: unknown; - }) => , - }; -}); - -// Mock the useUnrealizedPnL hook jest.mock('../../hooks/useUnrealizedPnL', () => ({ useUnrealizedPnL: jest.fn(), })); -// Mock usePredictDeposit hook const mockDeposit = jest.fn(); -const mockDepositResult = { - deposit: mockDeposit, - status: 'IDLE', -}; jest.mock('../../hooks/usePredictDeposit', () => ({ - usePredictDeposit: () => mockDepositResult, + usePredictDeposit: () => ({ + deposit: mockDeposit, + status: 'IDLE', + }), PredictDepositStatus: { IDLE: 'IDLE', PENDING: 'PENDING', @@ -232,7 +56,6 @@ jest.mock('../../hooks/usePredictDeposit', () => ({ }, })); -// Mock usePredictBalance hook const mockLoadBalance = jest.fn(); const mockBalanceResult: { balance: number | undefined; @@ -253,7 +76,6 @@ jest.mock('../../hooks/usePredictBalance', () => ({ usePredictBalance: () => mockBalanceResult, })); -// Mock usePredictActionGuard hook const mockExecuteGuardedAction = jest.fn(async (action) => await action()); jest.mock('../../hooks/usePredictActionGuard', () => ({ usePredictActionGuard: () => ({ @@ -263,46 +85,34 @@ jest.mock('../../hooks/usePredictActionGuard', () => ({ }), })); -// Mock usePredictClaimablePositions hook const mockLoadClaimablePositions = jest.fn(); -const mockClaimablePositionsResult: { - positions: PredictPosition[]; - isLoading: boolean; - error: string | null; - loadPositions: jest.Mock; -} = { - positions: [], - isLoading: false, - error: null, - loadPositions: mockLoadClaimablePositions, -}; jest.mock('../../hooks/usePredictPositions', () => ({ - usePredictPositions: () => mockClaimablePositionsResult, + usePredictPositions: () => ({ + positions: [], + isLoading: false, + error: null, + loadPositions: mockLoadClaimablePositions, + }), })); -// Mock usePredictClaim hook const mockClaim = jest.fn(); -const mockClaimResult = { - claim: mockClaim, - loading: false, - completed: false, - error: false, -}; jest.mock('../../hooks/usePredictClaim', () => ({ - usePredictClaim: () => mockClaimResult, + usePredictClaim: () => ({ + claim: mockClaim, + loading: false, + completed: false, + error: false, + }), })); -// Mock useNavigation const mockNavigate = jest.fn(); -const mockNavigationResult = { - navigate: mockNavigate, -}; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), - useNavigation: () => mockNavigationResult, + useNavigation: () => ({ + navigate: mockNavigate, + }), })); -// Mock strings jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string, params?: Record) => { const mockStrings: Record = { @@ -317,101 +127,30 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -// Helper function to create default props -function createDefaultProps() { - return { - availableBalance: 100.5, - totalClaimableAmount: 45.2, - onClaimPress: jest.fn(), - isLoading: false, - address: '0x1234567890123456789012345678901234567890', - providerId: 'polymarket', - }; -} - -// Helper function to set up test environment -function setupMarketsWonCardTest( - propsOverrides = {}, - hookOverrides = {}, - claimablePositionsOverrides: { positions?: Partial[] } = {}, -) { - // Reset mock results but keep the mock implementations - mockBalanceResult.balance = 100.5; - mockBalanceResult.isLoading = false; - mockBalanceResult.hasNoBalance = false; - mockBalanceResult.isRefreshing = false; - mockBalanceResult.error = null; - - mockClaimablePositionsResult.positions = []; - mockClaimablePositionsResult.isLoading = false; - mockClaimablePositionsResult.error = null; - - const defaultProps = createDefaultProps(); - const props = { - ...defaultProps, - ...propsOverrides, - }; - - // Configure balance mock based on props - if ('availableBalance' in propsOverrides) { - mockBalanceResult.balance = propsOverrides.availableBalance as - | number - | undefined; - } else { - mockBalanceResult.balance = props.availableBalance; - } - mockBalanceResult.isLoading = props.isLoading ?? false; - - // Mock the useUnrealizedPnL hook - const mockUseUnrealizedPnL = useUnrealizedPnL as jest.MockedFunction< - typeof useUnrealizedPnL - >; - mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 8.63, - percentUpnl: 3.9, - }, - isLoading: false, - isRefreshing: false, - error: null, - loadUnrealizedPnL: jest.fn(), - ...hookOverrides, - }); - - const ref = React.createRef<{ refresh: () => Promise }>(); - - // Test address and account ID to use in state +function createTestState(_availableBalance?: number, claimableAmount?: number) { const testAddress = '0x1234567890123456789012345678901234567890'; const testAccountId = 'test-account-id'; - // Build claimable positions for Redux state - const claimablePositionsArray = - claimablePositionsOverrides.positions !== undefined - ? (claimablePositionsOverrides.positions as unknown as PredictPosition[]) - : props.totalClaimableAmount - ? ([ - { - id: 'position-1', - status: PredictPositionStatus.WON, - cashPnl: props.totalClaimableAmount, - marketId: 'market-1', - tokenId: 'token-1', - outcome: 'Yes', - shares: '100', - avgPrice: 0.5, - currentValue: props.totalClaimableAmount, - }, - ] as unknown as PredictPosition[]) - : []; + const claimablePositions = claimableAmount + ? ([ + { + id: 'position-1', + status: PredictPositionStatus.WON, + cashPnl: claimableAmount, + currentValue: claimableAmount, + marketId: 'market-1', + title: 'Test Market', + outcome: 'Yes', + }, + ] as unknown as PredictPosition[]) + : []; - // Create Redux state with claimablePositions keyed by address - const state = { + return { engine: { backgroundState: { PredictController: { claimablePositions: { - [testAddress]: claimablePositionsArray, + [testAddress]: claimablePositions, }, }, AccountsController: { @@ -433,439 +172,63 @@ function setupMarketsWonCardTest( }, }, }; - - return { - ...renderWithProvider(, { state }), - props, - defaultProps, - mockUseUnrealizedPnL, - ref, - }; } describe('MarketsWonCard', () => { + const mockUseUnrealizedPnL = useUnrealizedPnL as jest.MockedFunction< + typeof useUnrealizedPnL + >; + beforeEach(() => { jest.clearAllMocks(); - // Reset mocks to defaults - mockDepositResult.status = 'IDLE'; mockBalanceResult.balance = 100.5; mockBalanceResult.isLoading = false; - mockClaimablePositionsResult.positions = []; - mockClaimablePositionsResult.isLoading = false; - mockClaimResult.loading = false; - mockClaimResult.completed = false; - mockClaimResult.error = false; + + mockUseUnrealizedPnL.mockReturnValue({ + unrealizedPnL: { + user: '0x1234567890123456789012345678901234567890', + cashUpnl: 8.63, + percentUpnl: 3.9, + }, + isLoading: false, + isRefreshing: false, + error: null, + loadUnrealizedPnL: jest.fn(), + }); }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); - describe('Component Rendering', () => { - it('renders claim button when totalClaimableAmount is provided', () => { - setupMarketsWonCardTest(); - - expect(screen.getByText('Claim $45.20')).toBeOnTheScreen(); - }); - - it('does not show claim button when totalClaimableAmount is undefined', () => { - const { totalClaimableAmount, ...propsWithoutClaimable } = - createDefaultProps(); - setupMarketsWonCardTest({ - ...propsWithoutClaimable, - totalClaimableAmount: undefined, - }); - - expect(screen.queryByText('Claim $45.20')).not.toBeOnTheScreen(); - }); - - it('renders main card when availableBalance is provided', () => { - setupMarketsWonCardTest(); + describe('rendering', () => { + it('displays available balance and unrealized P&L', () => { + const state = createTestState(100.5); - expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); - expect(screen.getByText('Available Balance')).toBeOnTheScreen(); - expect(screen.getByText('$100.50')).toBeOnTheScreen(); - }); - - it('renders main card when unrealized P&L is available', () => { - setupMarketsWonCardTest({ availableBalance: undefined }); + renderWithProvider(, { state }); expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - }); - - it('does not show main card when neither availableBalance nor unrealized P&L is available', () => { - setupMarketsWonCardTest( - { availableBalance: undefined }, - { unrealizedPnL: null }, - ); - - expect(screen.queryByTestId('markets-won-card')).not.toBeOnTheScreen(); - }); - - it('renders both available balance and unrealized P&L when both are available', () => { - setupMarketsWonCardTest(); - expect(screen.getByText('Available Balance')).toBeOnTheScreen(); expect(screen.getByText('$100.50')).toBeOnTheScreen(); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); expect(screen.getByText('+$8.63 (+3.9%)')).toBeOnTheScreen(); }); - it('renders claim button without loading indicator when isLoading is false', () => { - setupMarketsWonCardTest({ isLoading: false }); - - expect(screen.getByText('Claim $45.20')).toBeOnTheScreen(); - expect(screen.queryByTestId('activity-indicator')).not.toBeOnTheScreen(); - }); - }); - - describe('Amount Formatting', () => { - it('formats unrealized amount with correct sign and decimal places', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 123.456, - percentUpnl: 5.67, - }, - }, - ); - - expect(screen.getByText('+$123.46 (+5.67%)')).toBeOnTheScreen(); - }); - - it('formats negative unrealized amount correctly', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: -50.25, - percentUpnl: -2.1, - }, - }, - ); - - expect(screen.getByText('-$50.25 (-2.1%)')).toBeOnTheScreen(); - }); - - it('handles zero unrealized amount correctly', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 0, - percentUpnl: 0, - }, - }, - ); - - expect(screen.getByText('+$0.00 (+0%)')).toBeOnTheScreen(); - }); - - it('formats available balance to 2 decimal places', () => { - setupMarketsWonCardTest({ availableBalance: 123.4321 }); - - expect(screen.getByText('$123.43')).toBeOnTheScreen(); - }); - - it('formats claimable amount to 2 decimal places', () => { - setupMarketsWonCardTest({ totalClaimableAmount: 123.456 }); - - expect(screen.getByText('Claim $123.46')).toBeOnTheScreen(); - }); - - it('does not show available balance when it is 0', () => { - setupMarketsWonCardTest({ availableBalance: 0 }); - - expect(screen.queryByText('$0.00')).not.toBeOnTheScreen(); - }); - }); - - describe('Conditional Rendering Logic', () => { - it('shows main card when availableBalance is greater than 0', () => { - setupMarketsWonCardTest({ availableBalance: 50.25 }); - - expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); - expect(screen.getByText('Available Balance')).toBeOnTheScreen(); - expect(screen.getByText('$50.25')).toBeOnTheScreen(); - }); - - it('does not show available balance section when availableBalance is 0', () => { - setupMarketsWonCardTest({ availableBalance: 0 }); - - expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); - expect(screen.queryByText('Available Balance')).not.toBeOnTheScreen(); - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - }); - - it('hides main card when availableBalance is undefined', () => { - setupMarketsWonCardTest( - { availableBalance: undefined }, - { unrealizedPnL: null }, - ); - - expect(screen.queryByTestId('markets-won-card')).not.toBeOnTheScreen(); - }); - it('shows main card when unrealized P&L is available even without availableBalance', () => { - setupMarketsWonCardTest({ availableBalance: undefined }); + it('displays formatted balance value', () => { + mockBalanceResult.balance = 1234.56; + const state = createTestState(1234.56); - expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - }); - - it('shows both available balance and unrealized P&L when both are available', () => { - setupMarketsWonCardTest( - { availableBalance: 75.5 }, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 100, - percentUpnl: 10, - }, - }, - ); - - expect(screen.getByText('Available Balance')).toBeOnTheScreen(); - expect(screen.getByText('$75.50')).toBeOnTheScreen(); - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$100.00 (+10%)')).toBeOnTheScreen(); - }); - }); - - describe('Edge Cases', () => { - it('handles very large unrealized amounts', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 999999.99, - percentUpnl: 999.9, - }, - }, - ); - - expect(screen.getByText('+$999999.99 (+999.9%)')).toBeOnTheScreen(); - }); - - it('handles very small unrealized amounts', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 0.01, - percentUpnl: 0.1, - }, - }, - ); - - expect(screen.getByText('+$0.01 (+0.1%)')).toBeOnTheScreen(); - }); + renderWithProvider(, { state }); - it('handles very large available balance', () => { - setupMarketsWonCardTest({ availableBalance: 999999.99 }); - - expect(screen.getByText('$999,999.99')).toBeOnTheScreen(); - }); - - it('handles very small available balance', () => { - setupMarketsWonCardTest({ availableBalance: 0.01 }); - - expect(screen.getByText('$0.01')).toBeOnTheScreen(); - }); - - it('handles missing optional props gracefully', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 50, - percentUpnl: 5, - }, - }, - ); - - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$50.00 (+5%)')).toBeOnTheScreen(); + expect(screen.getByText('$1,234.56')).toBeOnTheScreen(); }); }); - describe('useUnrealizedPnL Hook Integration', () => { - it('calls useUnrealizedPnL hook with correct parameters', () => { - const { mockUseUnrealizedPnL } = setupMarketsWonCardTest(); - - expect(mockUseUnrealizedPnL).toHaveBeenCalledWith({ - providerId: 'polymarket', - }); - }); - - it('handles error state from hook', () => { - setupMarketsWonCardTest( - { availableBalance: undefined }, - { - error: 'Failed to fetch unrealized P&L', - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 0, - percentUpnl: 0, - }, - }, - ); - - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - // Should show fallback values when there's an error - expect(screen.getByText('+$0.00 (+0%)')).toBeOnTheScreen(); - }); - - it('handles null unrealized P&L data gracefully', () => { - setupMarketsWonCardTest( - { availableBalance: undefined }, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 0, - percentUpnl: 0, - }, - isLoading: false, - error: null, - }, - ); - - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - // Should show fallback values when data is null - expect(screen.getByText('+$0.00 (+0%)')).toBeOnTheScreen(); - }); - - it('displays correct unrealized P&L data from hook', () => { - setupMarketsWonCardTest( - {}, - { - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: -15.75, - percentUpnl: -8.2, - }, - isLoading: false, - error: null, - }, - ); - - expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('-$15.75 (-8.2%)')).toBeOnTheScreen(); - }); - - it('does not show unrealized P&L section when hook returns null data', () => { - setupMarketsWonCardTest( - { availableBalance: undefined }, - { - unrealizedPnL: null, - isLoading: false, - error: null, - }, - ); + describe('navigation', () => { + it('navigates to market list when balance area is pressed', () => { + const state = createTestState(50.25); - expect(screen.queryByTestId('markets-won-card')).not.toBeOnTheScreen(); - }); - }); - - describe('Position Filtering and Calculation', () => { - it('filters positions to only include those with WON status', () => { - const mixedPositions = [ - { - id: 'position-1', - status: PredictPositionStatus.WON, - cashPnl: 10.5, - marketId: 'market-1', - tokenId: 'token-1', - outcome: 'Yes', - shares: '100', - avgPrice: 0.5, - currentValue: 10.5, - }, - { - id: 'position-2', - status: PredictPositionStatus.OPEN, - cashPnl: 5.0, - marketId: 'market-2', - tokenId: 'token-2', - outcome: 'No', - shares: '50', - avgPrice: 0.6, - currentValue: 5.0, - }, - { - id: 'position-3', - status: PredictPositionStatus.WON, - cashPnl: 7.25, - marketId: 'market-3', - tokenId: 'token-3', - outcome: 'Yes', - shares: '75', - avgPrice: 0.4, - currentValue: 7.25, - }, - ]; - - setupMarketsWonCardTest( - { availableBalance: undefined }, - {}, - { - positions: mixedPositions, - }, - ); - - // Should show claim button since there are won positions - expect(screen.getByText('Claim $17.75')).toBeOnTheScreen(); - }); - - it('calculates total claimable amount by summing cashPnl of won positions', () => { - const wonPositions = [ - { - id: 'position-1', - status: PredictPositionStatus.WON, - cashPnl: 25.0, - marketId: 'market-1', - tokenId: 'token-1', - outcome: 'Yes', - shares: '100', - avgPrice: 0.5, - currentValue: 25.0, - }, - { - id: 'position-2', - status: PredictPositionStatus.WON, - cashPnl: 15.5, - marketId: 'market-2', - tokenId: 'token-2', - outcome: 'No', - shares: '50', - avgPrice: 0.6, - currentValue: 15.5, - }, - ]; - - setupMarketsWonCardTest( - { availableBalance: undefined }, - {}, - { - positions: wonPositions, - }, - ); - - // Should show claim button with sum of cashPnl values - expect(screen.getByText('Claim $40.50')).toBeOnTheScreen(); - }); - }); - - describe('View All Navigation', () => { - it('navigates to market list when available balance card is pressed', () => { - setupMarketsWonCardTest({ availableBalance: 100.5 }); + renderWithProvider(, { state }); const balanceTouchable = screen.getByTestId('markets-won-count').parent?.parent; @@ -873,6 +236,7 @@ describe('MarketsWonCard', () => { fireEvent.press(balanceTouchable); } + expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, params: { @@ -880,110 +244,139 @@ describe('MarketsWonCard', () => { }, }); }); + }); - it('navigates when balance is present and not loading', () => { - setupMarketsWonCardTest({ availableBalance: 50.25, isLoading: false }); - - const balanceTouchable = - screen.getByTestId('markets-won-count').parent?.parent; - if (balanceTouchable) { - fireEvent.press(balanceTouchable); - } - - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_LIST, - params: { - entryPoint: expect.any(String), + describe('refresh', () => { + it('reloads balance and unrealized P&L when refresh is called', async () => { + const mockLoadUnrealizedPnL = jest.fn(); + mockUseUnrealizedPnL.mockReturnValue({ + unrealizedPnL: { + user: '0x1234567890123456789012345678901234567890', + cashUpnl: 8.63, + percentUpnl: 3.9, }, + isLoading: false, + isRefreshing: false, + error: null, + loadUnrealizedPnL: mockLoadUnrealizedPnL, }); + const ref = React.createRef<{ refresh: () => Promise }>(); + const state = createTestState(100.5); + + renderWithProvider(, { state }); + + await ref.current?.refresh(); + + expect(mockLoadBalance).toHaveBeenCalledWith({ isRefresh: true }); + expect(mockLoadUnrealizedPnL).toHaveBeenCalledWith({ isRefresh: true }); }); + }); - it('does not render touchable area when balance is undefined', () => { - setupMarketsWonCardTest({ availableBalance: undefined }); + describe('loading states', () => { + it('displays skeleton loader when balance is loading', () => { + mockBalanceResult.isLoading = true; + mockBalanceResult.balance = 100.5; + const state = createTestState(100.5); - expect(screen.queryByTestId('markets-won-count')).not.toBeOnTheScreen(); + renderWithProvider(, { state }); + + expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); + expect(screen.getByTestId('markets-won-count')).toBeOnTheScreen(); }); - it('navigates with correct route structure', () => { - setupMarketsWonCardTest({ availableBalance: 200 }); + it('displays skeleton loader when unrealized P&L is loading', () => { + mockUseUnrealizedPnL.mockReturnValue({ + unrealizedPnL: { + user: '0x1234567890123456789012345678901234567890', + cashUpnl: 0, + percentUpnl: 0, + }, + isLoading: true, + isRefreshing: false, + error: null, + loadUnrealizedPnL: jest.fn(), + }); + const state = createTestState(100.5); - const balanceTouchable = - screen.getByTestId('markets-won-count').parent?.parent; - if (balanceTouchable) { - fireEvent.press(balanceTouchable); - } + renderWithProvider(, { state }); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining('Predict'), - expect.objectContaining({ - screen: expect.any(String), - }), - ); + expect(screen.getByTestId('markets-won-card')).toBeOnTheScreen(); }); }); - describe('User Interactions', () => { - it('calls onClaimPress when claim button is pressed', () => { - const mockOnClaimPress = jest.fn(); - const { props } = setupMarketsWonCardTest({ - onClaimPress: mockOnClaimPress, + describe('empty state', () => { + it('returns null when no data is available', () => { + mockBalanceResult.balance = undefined; + mockBalanceResult.isLoading = false; + mockUseUnrealizedPnL.mockReturnValue({ + unrealizedPnL: null, + isLoading: false, + isRefreshing: false, + error: null, + loadUnrealizedPnL: jest.fn(), }); + const state = createTestState(); + + const { toJSON } = renderWithProvider(, { state }); - // Verify the callback was passed correctly - expect(props.onClaimPress).toBe(mockOnClaimPress); + expect(toJSON()).toBeNull(); }); + }); - it('calls refresh method and triggers data reloading', async () => { - const mockLoadUnrealizedPnL = jest.fn(); - const { ref } = setupMarketsWonCardTest( - {}, - { - loadUnrealizedPnL: mockLoadUnrealizedPnL, - }, - ); + describe('error handling', () => { + it('calls onError callback when balance error occurs', () => { + const mockOnError = jest.fn(); + mockBalanceResult.error = 'Balance fetch failed'; + mockBalanceResult.balance = 100.5; + const state = createTestState(100.5); - await ref.current?.refresh(); + renderWithProvider(, { state }); - expect(mockLoadBalance).toHaveBeenCalledWith({ isRefresh: true }); - expect(mockLoadUnrealizedPnL).toHaveBeenCalledWith({ isRefresh: true }); + expect(mockOnError).toHaveBeenCalledWith('Balance fetch failed'); }); - it('handles missing onClaimPress callback gracefully', () => { - const { props } = setupMarketsWonCardTest({ onClaimPress: undefined }); + it('calls onError callback when P&L error occurs', () => { + const mockOnError = jest.fn(); + mockBalanceResult.error = null; + mockBalanceResult.balance = 100.5; + mockUseUnrealizedPnL.mockReturnValue({ + unrealizedPnL: { + user: '0x1234567890123456789012345678901234567890', + cashUpnl: 8.63, + percentUpnl: 3.9, + }, + isLoading: false, + isRefreshing: false, + error: 'P&L fetch failed', + loadUnrealizedPnL: jest.fn(), + }); + const state = createTestState(100.5); + + renderWithProvider(, { state }); - // Verify the callback is undefined - expect(props.onClaimPress).toBeUndefined(); + expect(mockOnError).toHaveBeenCalledWith('P&L fetch failed'); }); - it('uses fallback address when selectedAddress is undefined', () => { - // Arrange - create state with undefined selected account - const ref = React.createRef<{ refresh: () => Promise }>(); - const stateWithNoAddress = { - engine: { - backgroundState: { - PredictController: { - claimablePositions: { - '0x0': [], - }, - }, - AccountsController: { - internalAccounts: { - selectedAccount: undefined, - accounts: {}, - }, - }, - }, + it('prioritizes balance error over P&L error', () => { + const mockOnError = jest.fn(); + mockBalanceResult.error = 'Balance error'; + mockBalanceResult.balance = 100.5; + mockUseUnrealizedPnL.mockReturnValue({ + unrealizedPnL: { + user: '0x1234567890123456789012345678901234567890', + cashUpnl: 8.63, + percentUpnl: 3.9, }, - }; - - // Act - const { getByTestId } = renderWithProvider(, { - state: stateWithNoAddress, + isLoading: false, + isRefreshing: false, + error: 'P&L error', + loadUnrealizedPnL: jest.fn(), }); + const state = createTestState(100.5); + + renderWithProvider(, { state }); - // Assert - component renders without crashing - expect(getByTestId('markets-won-card')).toBeDefined(); + expect(mockOnError).toHaveBeenCalledWith('Balance error'); }); }); }); diff --git a/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.test.tsx b/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.test.tsx index 03fa649933d..ab4e72d7e95 100644 --- a/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.test.tsx +++ b/app/components/UI/Predict/components/PredictUnavailable/PredictUnavailable.test.tsx @@ -38,50 +38,6 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheet', - () => { - const ReactActual = jest.requireActual('react'); - const { View: RNView } = jest.requireActual('react-native'); - - return ReactActual.forwardRef( - ( - { - children, - onClose, - shouldNavigateBack: _shouldNavigateBack, - isInteractable: _isInteractable, - }: { - children: React.ReactNode; - onClose?: () => void; - shouldNavigateBack?: boolean; - isInteractable?: boolean; - }, - ref: React.Ref<{ - onOpenBottomSheet: (cb?: () => void) => void; - onCloseBottomSheet: (cb?: () => void) => void; - }>, - ) => { - ReactActual.useImperativeHandle(ref, () => ({ - onOpenBottomSheet: (cb?: () => void) => { - cb?.(); - }, - onCloseBottomSheet: (cb?: () => void) => { - onClose?.(); - cb?.(); - }, - })); - - return ReactActual.createElement( - RNView, - { testID: 'bottom-sheet' }, - children, - ); - }, - ); - }, -); - jest.mock('react-native-safe-area-context', () => ({ SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, useSafeAreaFrame: () => ({ x: 0, y: 0, width: 375, height: 812 }), @@ -99,103 +55,6 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader', - () => { - const ReactActual = jest.requireActual('react'); - const { - View: RNView, - Text: RNText, - TouchableOpacity: RNTouchableOpacity, - } = jest.requireActual('react-native'); - - return ReactActual.forwardRef( - ({ - children, - onClose, - style, - }: { - children: React.ReactNode; - onClose?: () => void; - style?: Record; - }) => - ReactActual.createElement( - RNView, - { testID: 'bottom-sheet-header', style }, - ReactActual.createElement( - RNTouchableOpacity, - { testID: 'header-close-button', onPress: onClose }, - ReactActual.createElement(RNText, null, '×'), - ), - children, - ), - ); - }, -); - -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter', - () => { - const ReactActual = jest.requireActual('react'); - const { - View: RNView, - Text: RNText, - TouchableOpacity: RNTouchableOpacity, - } = jest.requireActual('react-native'); - - return ({ - buttonPropsArray, - style, - }: { - buttonPropsArray: { - variant: string; - label: string; - onPress: () => void; - }[]; - style?: Record; - }) => - ReactActual.createElement( - RNView, - { testID: 'bottom-sheet-footer', style }, - buttonPropsArray.map((button, index) => - ReactActual.createElement( - RNTouchableOpacity, - { - key: index, - testID: `footer-button-${index}`, - onPress: button.onPress, - }, - ReactActual.createElement(RNText, null, button.label), - ), - ), - ); - }, -); - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - Text: 'Text', - TextVariant: { - HeadingMd: 'HeadingMd', - BodyMd: 'BodyMd', - }, - BoxAlignItems: { - Start: 'start', - }, - BoxJustifyContent: { - Start: 'start', - }, - ButtonSize: { - Lg: 'lg', - }, -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: (className: string) => ({ className }), - }), -})); - describe('PredictUnavailable', () => { const mockOnDismiss = jest.fn(); @@ -206,283 +65,104 @@ describe('PredictUnavailable', () => { }); afterEach(() => { - jest.clearAllMocks(); - mockRunAfterInteractions.mockReset(); + jest.resetAllMocks(); }); afterAll(() => { mockRunAfterInteractions.mockRestore(); }); - describe('Component Rendering', () => { - it('returns null when not visible', () => { + describe('rendering', () => { + it('hides sheet elements when not opened', () => { const ref = React.createRef(); render(); - expect(screen.queryByTestId('bottom-sheet')).toBeNull(); + expect(screen.queryByTestId('header')).not.toBeOnTheScreen(); }); - it('renders when opened via ref', () => { + it('displays sheet with header, terms link, and footer when opened', () => { const ref = React.createRef(); render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - expect(screen.getByTestId('bottom-sheet-header')).toBeOnTheScreen(); - }); - - it('renders all required text content', () => { - const ref = React.createRef(); - render(); act(() => { ref.current?.onOpenBottomSheet(); }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - expect(screen.getByText('See Polymarket terms')).toBeOnTheScreen(); - expect(screen.getByText('Got it')).toBeOnTheScreen(); - }); - - it('renders with correct component structure', () => { - const ref = React.createRef(); - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - expect(screen.getByTestId('bottom-sheet-header')).toBeOnTheScreen(); - expect(screen.getByTestId('bottom-sheet-footer')).toBeOnTheScreen(); + expect(screen.getByTestId('header')).toBeOnTheScreen(); + expect(screen.getByTestId('polymarket-terms-link')).toBeOnTheScreen(); + expect(screen.getByTestId('bottomsheetfooter')).toBeOnTheScreen(); }); }); - describe('User Interactions', () => { - it('calls onDismiss when header close button is pressed', () => { - // Arrange - const ref = React.createRef(); - - // Act - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - - const closeButton = screen.getByTestId('header-close-button'); - fireEvent.press(closeButton); - - // Assert - expect(mockOnDismiss).toHaveBeenCalled(); - }); - + describe('interactions', () => { it('calls onDismiss when footer button is pressed', () => { - // Arrange - const ref = React.createRef(); - - // Act - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - - const gotItButton = screen.getByTestId('footer-button-0'); - fireEvent.press(gotItButton); - - // Assert - expect(mockOnDismiss).toHaveBeenCalled(); - }); - - it('renders terms link with correct testID', () => { - // Arrange - const ref = React.createRef(); - - // Act - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - - // Assert - const termsLink = screen.getByTestId('polymarket-terms-link'); - expect(termsLink).toBeOnTheScreen(); - }); - }); - - describe('Ref Methods', () => { - it('opens bottom sheet when onOpenBottomSheet is called', () => { - const ref = React.createRef(); - - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - }); - - it('closes bottom sheet when onCloseBottomSheet is called', () => { - const ref = React.createRef(); - - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - - act(() => { - ref.current?.onCloseBottomSheet(); - }); - expect(mockOnDismiss).toHaveBeenCalled(); - }); - - it('handles multiple open calls gracefully', () => { const ref = React.createRef(); render(); - act(() => { - ref.current?.onOpenBottomSheet(); - ref.current?.onOpenBottomSheet(); - }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - }); - }); - - describe('Props Validation', () => { - it('renders without onDismiss prop', () => { - const ref = React.createRef(); - - render(); act(() => { ref.current?.onOpenBottomSheet(); }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - }); - it('handles undefined onDismiss gracefully', () => { - const ref = React.createRef(); - - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); + fireEvent.press(screen.getByTestId('bottomsheetfooter-button')); - const closeButton = screen.getByTestId('header-close-button'); - fireEvent.press(closeButton); - expect(screen.queryByTestId('bottom-sheet')).toBeNull(); + expect(mockOnDismiss).toHaveBeenCalledTimes(1); }); - }); - describe('Bottom Sheet Integration', () => { - it('passes correct props to BottomSheet', () => { + it('navigates to Polymarket terms webview when terms link is pressed', () => { const ref = React.createRef(); render(); act(() => { ref.current?.onOpenBottomSheet(); }); - expect(screen.getByText('Unavailable in your region')).toBeOnTheScreen(); - }); - it('handles bottom sheet close callback', () => { - const ref = React.createRef(); - - render(); + fireEvent.press(screen.getByTestId('polymarket-terms-link')); act(() => { - ref.current?.onOpenBottomSheet(); + runAfterInteractionsCallbacks.forEach((callback) => callback()); }); - const closeButton = screen.getByTestId('header-close-button'); - fireEvent.press(closeButton); - expect(mockOnDismiss).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + 'Webview', + expect.objectContaining({ + screen: 'SimpleWebview', + params: expect.objectContaining({ + url: 'https://polymarket.com/tos', + }), + }), + ); }); }); - describe('Terms Link', () => { - it('renders terms link as touchable', () => { - // Arrange + describe('ref methods', () => { + it('opens sheet when onOpenBottomSheet is called', () => { const ref = React.createRef(); - // Act render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - - // Assert - const termsLink = screen.getByTestId('polymarket-terms-link'); - expect(termsLink).toBeOnTheScreen(); - }); - - it('displays terms text with correct styling', () => { - // Arrange - const ref = React.createRef(); - // Act - render(); act(() => { ref.current?.onOpenBottomSheet(); }); - // Assert - expect(screen.getByText('See Polymarket terms')).toBeOnTheScreen(); + expect(screen.getByTestId('header')).toBeOnTheScreen(); + expect(screen.getByTestId('polymarket-terms-link')).toBeOnTheScreen(); }); - it('renders terms link with onPress handler', () => { - // Arrange + it('closes sheet and calls onDismiss when onCloseBottomSheet is called', () => { const ref = React.createRef(); - // Act render(); act(() => { ref.current?.onOpenBottomSheet(); }); - // Assert - const termsLink = screen.getByTestId('polymarket-terms-link'); - expect(termsLink.props.onPress).toBeDefined(); - expect(typeof termsLink.props.onPress).toBe('function'); - }); - - it('navigates to polymarket terms webview when link is pressed', () => { - // Arrange - const ref = React.createRef(); - - // Act - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - const termsLink = screen.getByTestId('polymarket-terms-link'); act(() => { - termsLink.props.onPress(); + ref.current?.onCloseBottomSheet(); }); - // Assert - expect(mockRunAfterInteractions).toHaveBeenCalledTimes(1); - const callback = runAfterInteractionsCallbacks[0]; - expect(callback).toBeDefined(); - callback?.(); - expect(mockNavigate).toHaveBeenCalledWith('Webview', { - screen: 'SimpleWebview', - params: { - url: 'https://polymarket.com/tos', - title: 'Polymarket Terms', - }, - }); - }); - }); - - describe('Accessibility', () => { - it('renders all interactive elements', () => { - const ref = React.createRef(); - - render(); - act(() => { - ref.current?.onOpenBottomSheet(); - }); - expect(screen.getByTestId('header-close-button')).toBeOnTheScreen(); - expect(screen.getByTestId('footer-button-0')).toBeOnTheScreen(); - expect(screen.getByText('See Polymarket terms')).toBeOnTheScreen(); + expect(mockOnDismiss).toHaveBeenCalledTimes(1); }); }); }); diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 61e93c64993..ac620fd0a72 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -12,11 +12,6 @@ import { } from './format'; import { Recurrence, PredictSeries } from '../types'; -// Mock the formatWithThreshold utility -jest.mock('../../../../util/assets', () => ({ - formatWithThreshold: jest.fn(), -})); - // Mock Dimensions from react-native const mockDimensionsGet = jest.fn(() => ({ width: 375, diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx index 7fe889b39ed..acfeced043c 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx @@ -3,7 +3,7 @@ import { RouteProp, StackActions, } from '@react-navigation/native'; -import { fireEvent, act } from '@testing-library/react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; import React from 'react'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; @@ -119,22 +119,6 @@ jest.mock('../../hooks/usePredictRewards', () => ({ }), })); -// Mock Skeleton component -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => { - const { View, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ width, height }: { width: number; height: number }) => ( - - Loading... - - ), - }; - }, -); - // Mock format utilities jest.mock('../../utils/format', () => ({ formatPrice: jest.fn( @@ -156,78 +140,6 @@ jest.mock('../../utils/format', () => ({ }), })); -// Mock SafeAreaView -jest.mock('react-native-safe-area-context', () => ({ - SafeAreaView: ({ children }: { children: React.ReactNode }) => children, -})); - -// Mock Image component to avoid image loading issues -jest.mock('react-native', () => ({ - ...jest.requireActual('react-native'), - Image: ({ source, style }: { source: { uri: string }; style?: object }) => ( -
- ), -})); - -// Mock Keypad component -let capturedOnChange: - | ((params: { value: string; valueAsNumber: number }) => void) - | null = null; -jest.mock('../../../../Base/Keypad', () => { - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - value, - onChange, - style, - }: { - value: string; - onChange: (params: { value: string; valueAsNumber: number }) => void; - style?: object; - }) => { - capturedOnChange = onChange; - return ( - onChange({ value: '100', valueAsNumber: 100 })} - testID="keypad" - style={style} - > - Keypad: {value} - - ); - }, - }; -}); - -// Mock PredictAmountDisplay component -jest.mock('../../components/PredictAmountDisplay', () => { - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - amount, - onPress, - isActive, - hasError, - }: { - amount: string; - onPress?: () => void; - isActive?: boolean; - hasError?: boolean; - }) => ( - - {amount} - - ), - }; -}); - const mockMarket: PredictMarket = { id: 'market-123', providerId: 'polymarket', @@ -346,250 +258,98 @@ describe('PredictBuyPreview', () => { }); describe('initial rendering', () => { - it('renders place bet screen with market and outcome information', () => { - const { getByText, getByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - expect(getByText('Will Bitcoin reach $150,000?')).toBeOnTheScreen(); - expect(getByText('Yes at 50Âĸ')).toBeOnTheScreen(); - expect(getByText('To win')).toBeOnTheScreen(); - expect(getByText('$120.00')).toBeOnTheScreen(); - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); - expect(getByTestId('keypad')).toBeOnTheScreen(); - }); - - it('displays correct fee breakdown when done button is pressed', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Press done to show fee summary - const doneButton = getByText('Done'); - fireEvent.press(doneButton); + it('renders market title and outcome information', () => { + renderWithProvider(, { state: initialState }); - // Fee calculations are tested by the rendered text content + expect( + screen.getByText('Will Bitcoin reach $150,000?'), + ).toBeOnTheScreen(); + expect(screen.getByText('Yes at 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('To win')).toBeOnTheScreen(); + expect(screen.getByText('$120.00')).toBeOnTheScreen(); }); - it('shows disclaimer text after pressing done', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); + it('displays disclaimer text when done button is pressed', () => { + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - // Press done to show bottom content - const doneButton = getByText('Done'); fireEvent.press(doneButton); expect( - getByText(/By continuing, you accept Polymarket.s terms\./), + screen.getByText(/By continuing, you accept Polymarket.s terms\./), ).toBeOnTheScreen(); }); }); describe('amount input functionality', () => { - it('deactivates amount input when done button is pressed', () => { - const { getByTestId, getByText, queryByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Initially active - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); - expect(getByTestId('keypad')).toBeOnTheScreen(); - - // Press done button - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - expect(queryByTestId('keypad')).toBeNull(); - expect(getByTestId('amount-display-inactive')).toBeOnTheScreen(); - }); - - it('reactivates input when amount display is pressed after done', () => { - const { getByTestId, getByText, queryByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Press done to deactivate - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Now press amount display to reactivate - const amountDisplay = getByTestId('amount-display-inactive'); - fireEvent.press(amountDisplay); - - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); - expect(getByTestId('keypad')).toBeOnTheScreen(); - expect(queryByTestId('amount-display-inactive')).toBeNull(); - }); - - it('updates amount when keypad quick amount buttons are pressed', () => { - const { getByTestId, getByText } = renderWithProvider( - , - { - state: initialState, - }, - ); + it('displays expected win amount from preview', () => { + mockExpectedAmount = 240; - // Keypad is already visible - // Press $50 button - const fiftyButton = getByText('$50'); - fireEvent.press(fiftyButton); + renderWithProvider(, { state: initialState }); - // Verify keypad exists and is rendered - const keypad = getByTestId('keypad'); - expect(keypad).toBeOnTheScreen(); + expect(screen.getByText('To win')).toBeOnTheScreen(); + expect(screen.getByText('$240.00')).toBeOnTheScreen(); }); - it('updates expected win amount when input changes', () => { - mockExpectedAmount = 240; // Double the amount should double expected win - - const { getByText } = renderWithProvider(, { - state: initialState, - }); + it('shows quick amount buttons on initial render', () => { + renderWithProvider(, { state: initialState }); - expect(getByText('To win')).toBeOnTheScreen(); - expect(getByText('$240.00')).toBeOnTheScreen(); + expect(screen.getByText('$20')).toBeOnTheScreen(); + expect(screen.getByText('$50')).toBeOnTheScreen(); + expect(screen.getByText('$100')).toBeOnTheScreen(); }); }); describe('place bet functionality', () => { - it('places bet when place bet button is pressed', async () => { - const mockResult = { success: true, txMeta: { id: 'test' } }; - mockPlaceOrder.mockReturnValue(mockResult); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); + it('displays place bet button after pressing done', () => { + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - // Enter valid amount (minimum $1) - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - // Press done to show place bet button - const doneButton = getByText('Done'); fireEvent.press(doneButton); - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - await act(async () => { - fireEvent.press(placeBetButton); - }); - - expect(mockPlaceOrder).toHaveBeenCalledWith({ - providerId: 'polymarket', - preview: expect.objectContaining({ - marketId: 'market-123', - outcomeId: 'outcome-456', - outcomeTokenId: 'outcome-token-789', - side: 'BUY', - }), - analyticsProperties: expect.objectContaining({ - marketId: 'market-123', - marketTitle: 'Will Bitcoin reach $150,000?', - marketCategory: 'crypto', - marketTags: expect.any(Array), - entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, - transactionType: PredictEventValues.TRANSACTION_TYPE.MM_PREDICT_BUY, - liquidity: 1000000, - volume: 1000000, - sharePrice: 0.5, - }), - }); + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); - it('navigates to market list after successful bet placement', async () => { + it('dispatches pop action when result is marked as successful', () => { mockPlaceOrderResult = { success: true, response: { transactionHash: '0xabc123' }, }; - - const { getByText, rerender } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Press done to show place bet button - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - - await act(async () => { - fireEvent.press(placeBetButton); + const { rerender } = renderWithProvider(, { + state: initialState, }); - // Rerender to trigger useEffect with result rerender(); expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); }); - it('disables place bet button when loading', () => { - mockLoadingState = true; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Press done to show place bet button and bottom content - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Now the button and disclaimer text should be visible - expect( - getByText(/By continuing, you accept Polymarket.s terms\./), - ).toBeOnTheScreen(); - }); - - it('shows loading state on place bet button when loading', () => { + it('displays disclaimer text when loading state is active', () => { mockLoadingState = true; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - // Press done to show place bet button and bottom content - const doneButton = getByText('Done'); fireEvent.press(doneButton); - // When loading, the button area should still show the disclaimer text expect( - getByText(/By continuing, you accept Polymarket.s terms\./), + screen.getByText(/By continuing, you accept Polymarket.s terms\./), ).toBeOnTheScreen(); - // The loading state is tested implicitly by the component behavior }); }); describe('navigation', () => { - it('navigates back when back button is pressed', () => { - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); + it('calls goBack when back button is pressed', () => { + renderWithProvider(, { state: initialState }); + const backButton = screen.getByTestId('back-button'); - const backButton = getByTestId('back-button'); fireEvent.press(backButton); expect(mockGoBack).toHaveBeenCalled(); }); - it('uses correct navigation hooks', () => { - renderWithProvider(, { - state: initialState, - }); + it('calls navigation hooks on component mount', () => { + renderWithProvider(, { state: initialState }); expect(mockUseNavigation).toHaveBeenCalled(); expect(mockUseRoute).toHaveBeenCalled(); @@ -597,57 +357,13 @@ describe('PredictBuyPreview', () => { }); describe('market display variations', () => { - it('displays single outcome correctly when market has one outcome with multiple tokens', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - expect(getByText('Yes at 50Âĸ')).toBeOnTheScreen(); - }); - - it('displays single outcome correctly when market has one outcome', () => { - const singleOutcomeMarket = { - ...mockMarket, - outcomes: [ - { - ...mockMarket.outcomes[0], - tokens: [ - { - id: 'outcome-token-single', - title: 'Yes', - price: 0.75, // $0.75 - }, - ], - }, - ], - }; - - const singleOutcomeRoute = { - ...mockRoute, - params: { - ...mockRoute.params, - market: singleOutcomeMarket, - outcomeToken: { - id: 'outcome-token-single', - title: 'Yes', - price: 0.75, - }, - outcomeTokenId: 'outcome-token-single', - }, - }; - - // Set up the mock before rendering - mockUseRoute.mockReturnValue(singleOutcomeRoute); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); + it('displays Yes outcome with price when market has single outcome', () => { + renderWithProvider(, { state: initialState }); - // Component now uses preview.sharePrice (0.5) instead of outcome token price - expect(getByText('Yes at 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('Yes at 50Âĸ')).toBeOnTheScreen(); }); - it('displays multiple outcomes correctly when market has multiple outcomes', () => { + it('displays group title when market has multiple outcomes', () => { const multipleOutcomesMarket = { ...mockMarket, outcomes: [ @@ -658,17 +374,18 @@ describe('PredictBuyPreview', () => { { id: 'outcome-457', marketId: 'market-123', + providerId: 'polymarket', title: 'Second Outcome', description: 'Second outcome description', image: 'https://example.com/outcome2.png', - status: 'open', + status: 'open' as const, volume: 500000, groupItemTitle: 'Market Cap', tokens: [ { id: 'outcome-token-791', title: 'Yes', - price: 0.3, // $0.30 + price: 0.3, }, ], negRisk: false, @@ -676,7 +393,6 @@ describe('PredictBuyPreview', () => { }, ], }; - mockUseRoute.mockReturnValue({ ...mockRoute, params: { @@ -686,1261 +402,328 @@ describe('PredictBuyPreview', () => { }, }); - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - expect(getByText('Bitcoin Price')).toBeOnTheScreen(); - expect(getByText('Yes at 50Âĸ')).toBeOnTheScreen(); - }); - - it('applies correct colors for Yes and No outcomes', () => { - // Testing Yes outcome (should use success color) - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - const yesText = getByText('Yes at 50Âĸ'); - expect(yesText).toBeOnTheScreen(); + renderWithProvider(, { state: initialState }); - // The color styling is applied via tw.style, which would need visual testing - // This test verifies the text is present and correct + expect(screen.getByText('Bitcoin Price')).toBeOnTheScreen(); + expect(screen.getByText('Yes at 50Âĸ')).toBeOnTheScreen(); }); - it('applies error color for No outcome', () => { - // Create a market with No outcome token - const noOutcomeMarket = { - ...mockMarket, - outcomes: [ - { - ...mockMarket.outcomes[0], - tokens: [ - { - id: 'outcome-token-no', - title: 'No', - price: 0.6, // $0.60 - }, - ], - }, - ], - }; - + it('displays No outcome with price when token title is No', () => { const noOutcomeRoute = { ...mockRoute, params: { ...mockRoute.params, - market: noOutcomeMarket, outcomeToken: { id: 'outcome-token-no', title: 'No', price: 0.6, }, - outcomeTokenId: 'outcome-token-no', }, }; - - // Set up the mock before rendering mockUseRoute.mockReturnValue(noOutcomeRoute); - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Component now uses preview.sharePrice (0.5) instead of outcome token price - const noText = getByText('No at 50Âĸ'); - expect(noText).toBeOnTheScreen(); - - // The error color styling is applied via tw.style for No outcomes - }); - }); - - describe('input validation', () => { - it('limits input to 9 digits', () => { - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); - - // Keypad is already active initially - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); - - // Simulate entering a 10-digit number (should be ignored due to 9-digit limit) - act(() => { - capturedOnChange?.({ - value: '1234567890', // 10 digits - should be blocked - valueAsNumber: 1234567890, - }); - }); - - // The input should not change since it exceeded the 9-digit limit - // The component should ignore the input and not update state - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); - }); - - it('limits decimal places to 2', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Keypad is already active, simulate entering a number with more than 2 decimal places - act(() => { - capturedOnChange?.({ - value: '123.45678', - valueAsNumber: 123.45678, - }); - }); - - // The component should limit to 2 decimal places - expect(getByText('123.45')).toBeOnTheScreen(); - }); - - it('handles decimal point deletion correctly', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Keypad is already active, set initial value with decimal - act(() => { - capturedOnChange?.({ - value: '2.5', - valueAsNumber: 2.5, - }); - }); - - // Now simulate trying to delete when stuck on decimal (this tests the specific branch) - act(() => { - capturedOnChange?.({ - value: '2.', // Same as previous but trying to delete decimal - valueAsNumber: 2.0, - }); - }); - - expect(getByText('2')).toBeOnTheScreen(); - }); - - it('handles decimal point deletion in middle of number', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Keypad is already active, set initial value with decimal - act(() => { - capturedOnChange?.({ - value: '25.5', - valueAsNumber: 25.5, - }); - }); - - // Simulate deleting a digit after decimal (should remove decimal too) - act(() => { - capturedOnChange?.({ - value: '25.', // This triggers the middle deletion logic - valueAsNumber: 25.0, - }); - }); - - expect(getByText('25')).toBeOnTheScreen(); - }); - - it('maintains input focus when keypad changes', () => { - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); - - // Initially focused - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); - - // Simulate keypad input change - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); + renderWithProvider(, { state: initialState }); - // Should still show active display - expect(getByTestId('amount-display-active')).toBeOnTheScreen(); + expect(screen.getByText('No at 50Âĸ')).toBeOnTheScreen(); }); - it('preserves decimal point when user just types it', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); + it('displays custom outcome title when token title is neither Yes nor No', () => { + const customOutcomeRoute = { + ...mockRoute, + params: { + ...mockRoute.params, + outcomeToken: { + id: 'outcome-token-custom', + title: 'Maybe', + price: 0.75, + }, + }, + }; + mockUseRoute.mockReturnValue(customOutcomeRoute); - // Keypad is already active, simulate typing just a decimal point - act(() => { - capturedOnChange?.({ - value: '2.', - valueAsNumber: 2.0, - }); - }); + renderWithProvider(, { state: initialState }); - // Should preserve the decimal point for user to continue typing - expect(getByText('2.')).toBeOnTheScreen(); + expect(screen.getByText('Maybe at 50Âĸ')).toBeOnTheScreen(); }); }); describe('input focus behavior', () => { - it('shows summary when input is unfocused', () => { - const { getByText, queryByText } = renderWithProvider( - , - { - state: initialState, - }, - ); + it('hides fees summary on initial render when input is focused', () => { + renderWithProvider(, { state: initialState }); - // Initially focused, summary should be hidden - expect(queryByText('Provider fee')).not.toBeOnTheScreen(); + expect(screen.queryByText('Provider fee')).not.toBeOnTheScreen(); + }); + + it('shows fees summary when done button is pressed', () => { + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - // Press done to unfocus - const doneButton = getByText('Done'); fireEvent.press(doneButton); - // Summary should now be visible (consolidated fees row) - expect(queryByText('Fees')).toBeOnTheScreen(); + expect(screen.queryByText('Fees')).toBeOnTheScreen(); }); - it('shows bottom content when input is unfocused', () => { - const { getByText, queryByText } = renderWithProvider( - , - { - state: initialState, - }, - ); + it('hides disclaimer on initial render when input is focused', () => { + renderWithProvider(, { state: initialState }); - // Initially focused, bottom content should be hidden expect( - queryByText(/By continuing, you accept Polymarket.s terms\./), + screen.queryByText(/By continuing, you accept Polymarket.s terms\./), ).not.toBeOnTheScreen(); - - // Press done to unfocus - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Bottom content should now be visible - expect( - queryByText(/By continuing, you accept Polymarket.s terms\./), - ).toBeOnTheScreen(); }); - it('hides keypad when input is unfocused', () => { - const { getByTestId, getByText, queryByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Initially focused, keypad should be visible - expect(getByTestId('keypad')).toBeOnTheScreen(); + it('shows disclaimer when done button is pressed', () => { + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - // Press done to unfocus - const doneButton = getByText('Done'); fireEvent.press(doneButton); - // Keypad should now be hidden - expect(queryByTestId('keypad')).toBeNull(); + expect( + screen.queryByText(/By continuing, you accept Polymarket.s terms\./), + ).toBeOnTheScreen(); }); }); - describe('error handling', () => { - it('calls dispatch when result is successful', async () => { + describe('success handling', () => { + it('dispatches pop action when place order result is successful', () => { mockPlaceOrderResult = { success: true, response: { transactionHash: '0xabc123' }, }; - - const { getByText, rerender } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter valid amount (minimum $1) - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - // Press done to show place bet button - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - - await act(async () => { - fireEvent.press(placeBetButton); + const { rerender } = renderWithProvider(, { + state: initialState, }); - // PlaceOrder is called when button is pressed - expect(mockPlaceOrder).toHaveBeenCalled(); - - // Rerender to trigger useEffect with result rerender(); - // Dispatch is called via useEffect when result is successful expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); }); }); describe('balance loading and display', () => { - it('displays balance when loaded', () => { + it('displays formatted balance when balance is loaded', () => { mockBalance = 1000; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - expect(getByText('Available: $1,000.00')).toBeOnTheScreen(); + expect(screen.getByText('Available: $1,000.00')).toBeOnTheScreen(); }); - it('shows skeleton loader while balance is loading', () => { + it('hides balance text while balance is loading', () => { mockBalanceLoading = true; - const { getByTestId, queryByText } = renderWithProvider( - , - { - state: initialState, - }, - ); + renderWithProvider(, { state: initialState }); - expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); - expect(queryByText(/Available:/)).not.toBeOnTheScreen(); + expect(screen.queryByText(/Available:/)).not.toBeOnTheScreen(); }); - it('displays correct balance format with 2 decimal places', () => { + it('displays balance with 2 decimal places', () => { mockBalance = 1234.56; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - expect(getByText('Available: $1,234.56')).toBeOnTheScreen(); + expect(screen.getByText('Available: $1,234.56')).toBeOnTheScreen(); }); - it('handles zero balance correctly', () => { + it('displays zero balance as $0.00', () => { mockBalance = 0; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - expect(getByText('Available: $0.00')).toBeOnTheScreen(); + expect(screen.getByText('Available: $0.00')).toBeOnTheScreen(); }); - it('handles large balance values correctly', () => { + it('formats large balance with commas', () => { mockBalance = 999999.99; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - expect(getByText('Available: $999,999.99')).toBeOnTheScreen(); + expect(screen.getByText('Available: $999,999.99')).toBeOnTheScreen(); }); }); describe('insufficient funds validation', () => { - it('shows error message when total exceeds balance', () => { + it('hides insufficient funds error on initial render with zero amount', () => { mockBalance = 50; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount that exceeds balance (50 + 1.5 fees = 51.5 > 50) - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); + renderWithProvider(, { state: initialState }); - // Error should show immediately even with keypad open - // maxBetAmount = balance - (providerFee + metamaskFee) = 50 - 1.5 = 48.5 expect( - getByText('Not enough funds. You can use up to $48.50.'), - ).toBeOnTheScreen(); + screen.queryByText(/Not enough funds\. You can use up to/), + ).not.toBeOnTheScreen(); }); - it('displays amount in error color when insufficient funds', () => { - mockBalance = 50; + it('displays available balance when balance is loaded', () => { + mockBalance = 1.5; mockBalanceLoading = false; - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount that exceeds balance - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); + renderWithProvider(, { state: initialState }); - // Check that amount display has error state - expect(getByTestId('amount-display-active-error')).toBeOnTheScreen(); + expect(screen.getByText(/Available:.*\$1\.50/)).toBeOnTheScreen(); }); - it('error message appears at bottom above keypad when keypad is open', () => { + it('hides insufficient funds error when balance is loading', () => { mockBalance = 50; - mockBalanceLoading = false; - - const { getByText, getByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter amount that exceeds balance - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); + mockBalanceLoading = true; - // Keypad should be visible (input is focused) - expect(getByTestId('keypad')).toBeOnTheScreen(); + renderWithProvider(, { state: initialState }); - // Error message should be present - // maxBetAmount = balance - (providerFee + metamaskFee) = 50 - 1.5 = 48.5 expect( - getByText('Not enough funds. You can use up to $48.50.'), - ).toBeOnTheScreen(); + screen.queryByText(/Not enough funds\. You can use up to/), + ).not.toBeOnTheScreen(); }); + }); - it('error message appears at bottom above button when keypad is closed', () => { - mockBalance = 50; + describe('minimum bet validation', () => { + it('hides minimum bet error on initial render when amount is $0', () => { + mockBalance = 1000; mockBalanceLoading = false; - const { getByText, queryByTestId } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter valid amount first - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // Close keypad - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Keypad should not be visible - expect(queryByTestId('keypad')).not.toBeOnTheScreen(); - - // Now open keypad and enter insufficient amount - const amountDisplay = getByText('10'); - fireEvent.press(amountDisplay); - - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); - - // Error message should be present even with keypad open - // maxBetAmount = balance - (providerFee + metamaskFee) = 50 - 1.5 = 48.5 - expect( - getByText('Not enough funds. You can use up to $48.50.'), - ).toBeOnTheScreen(); - }); - - it('does not show insufficient funds error when total equals balance', () => { - mockBalance = 51.5; - mockBalanceLoading = false; - - const { queryByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount where total equals balance (50 + 1.5 fees = 51.5) - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); + renderWithProvider(, { state: initialState }); expect( - queryByText(/Not enough funds\. You can use up to/), + screen.queryByText('Minimum amount is $1.00'), ).not.toBeOnTheScreen(); }); - it('calculates total including fees for validation', () => { - mockBalance = 100; + it('hides minimum bet error when amount equals $1', () => { + mockBalance = 1000; mockBalanceLoading = false; - mockMetamaskFee = 5; - mockProviderFee = 10; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter 90 (90 + 15 fees = 105 > 100 balance) - act(() => { - capturedOnChange?.({ - value: '90', - valueAsNumber: 90, - }); - }); - - // Error should show immediately - // maxBetAmount = balance - (providerFee + metamaskFee) = 100 - 15 = 85 - expect( - getByText('Not enough funds. You can use up to $85.00.'), - ).toBeOnTheScreen(); - }); - - it('hides error message when balance is loading', () => { - mockBalance = 50; - mockBalanceLoading = true; - - const { queryByText } = renderWithProvider(, { - state: initialState, - }); - // Enter amount that would exceed balance - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); + renderWithProvider(, { state: initialState }); - // Should not show error while loading expect( - queryByText(/Not enough funds\. You can use up to/), + screen.queryByText('Minimum amount is $1.00'), ).not.toBeOnTheScreen(); }); - }); - - describe('minimum bet validation', () => { - it('shows minimum bet error when amount is below $1', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount below minimum - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); - - // Press done to show error - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - expect(getByText('Minimum amount is $1.00')).toBeOnTheScreen(); - }); - - it('does not show minimum bet error when amount is exactly $1', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { queryByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter minimum amount - act(() => { - capturedOnChange?.({ - value: '1', - valueAsNumber: 1, - }); - }); - - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); - }); - - it('does not show minimum bet error when amount is $0', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { queryByText } = renderWithProvider(, { - state: initialState, - }); - // Amount starts at 0 - should not show error - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); - }); - - it('does not show minimum bet error when insufficient funds error is shown', () => { + it('prioritizes insufficient funds error over minimum bet error', () => { mockBalance = 0.5; mockBalanceLoading = false; - const { getByText, queryByText } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter amount below minimum AND that exceeds balance - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); + renderWithProvider(, { state: initialState }); - // Should show insufficient funds, not minimum bet - // Note: Done button is replaced by Add funds when insufficient - // maxBetAmount = balance - (providerFee + metamaskFee) = 0.5 - 1.5 = -1 expect( - getByText('Not enough funds. You can use up to $-1.00.'), + screen.getByText('Not enough funds. You can use up to $-1.00.'), ).toBeOnTheScreen(); - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); - }); - - it('minimum bet error appears at bottom like insufficient funds error', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount below minimum - act(() => { - capturedOnChange?.({ - value: '0.75', - valueAsNumber: 0.75, - }); - }); - - // Press done to close keypad - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Error should be visible at bottom - expect(getByText('Minimum amount is $1.00')).toBeOnTheScreen(); + expect( + screen.queryByText('Minimum amount is $1.00'), + ).not.toBeOnTheScreen(); }); }); describe('add funds functionality', () => { - it('shows Add funds button in keypad when insufficient funds', () => { - mockBalance = 50; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount that exceeds balance - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); - - // Should show "Add funds" button - expect(getByText('Add funds')).toBeOnTheScreen(); - }); - - it('hides quick action buttons when insufficient funds', () => { - mockBalance = 50; - mockBalanceLoading = false; - - const { queryByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount that exceeds balance - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); - - // Quick action buttons should not be visible - expect(queryByText('$20')).not.toBeOnTheScreen(); - expect(queryByText('$50')).not.toBeOnTheScreen(); - expect(queryByText('$100')).not.toBeOnTheScreen(); - expect(queryByText('Done')).not.toBeOnTheScreen(); - }); - - it('shows normal action buttons when funds are sufficient', () => { + it('shows quick action buttons on initial render with sufficient balance', () => { mockBalance = 1000; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount within balance - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - // Quick action buttons should be visible - expect(getByText('$20')).toBeOnTheScreen(); - expect(getByText('$50')).toBeOnTheScreen(); - expect(getByText('$100')).toBeOnTheScreen(); - expect(getByText('Done')).toBeOnTheScreen(); - }); - - it('calls deposit when Add funds button in keypad is pressed', () => { - mockBalance = 50; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount that exceeds balance - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); - - // Click "Add funds" button - const addFundsButton = getByText('Add funds'); - fireEvent.press(addFundsButton); + renderWithProvider(, { state: initialState }); - expect(mockDeposit).toHaveBeenCalled(); + expect(screen.getByText('$20')).toBeOnTheScreen(); + expect(screen.getByText('$50')).toBeOnTheScreen(); + expect(screen.getByText('$100')).toBeOnTheScreen(); + expect(screen.getByText('Done')).toBeOnTheScreen(); }); - it('shows Add funds button in bottom content when keypad closed and insufficient funds', () => { + it('shows quick action buttons on initial render even with low balance', () => { mockBalance = 50; mockBalanceLoading = false; - const { getAllByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount that exceeds balance - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); + renderWithProvider(, { state: initialState }); - // Close keypad - but we still have insufficient funds - // The Add funds button in keypad should hide the Done button - // So we shouldn't be able to close the keypad in this state - // Actually looking at the code, when insufficient funds, - // the Add funds button replaces the quick actions including Done - - // Verify Add funds button is shown (in keypad) - expect(getAllByText('Add funds').length).toBeGreaterThan(0); + expect(screen.getByText('$20')).toBeOnTheScreen(); + expect(screen.getByText('$50')).toBeOnTheScreen(); + expect(screen.getByText('$100')).toBeOnTheScreen(); + expect(screen.getByText('Done')).toBeOnTheScreen(); }); }); describe('place bet button validation', () => { - it('disables place bet button when amount below minimum bet', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter below minimum - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); - - // For this test, we need to somehow close the keypad - // But we can't because there's no Done button when amount is below minimum - // Let's verify that when we DO have a valid amount, the button works - - // Reset to valid amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Now back to below minimum - const amountDisplay = getByText('10'); - fireEvent.press(amountDisplay); - - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); - - // The validation happens in canPlaceBet which is tested through button disabled state - }); - - it('replaces place bet button with Add funds when insufficient funds', () => { - mockBalance = 50; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter valid amount first to close keypad - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Should show place bet button - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); - - // Now enter amount that exceeds balance - const amountDisplay = getByText('10'); - fireEvent.press(amountDisplay); - - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); - - // Can't check bottom button because keypad is now open - // and insufficient funds shows Add funds in keypad - }); - - it('enables place bet button when all conditions met', () => { + it('displays place bet button when done button is pressed', () => { mockBalance = 1000; mockBalanceLoading = false; mockRewardsLoading = false; - // Reset mockDispatch to not throw - mockDispatch.mockClear(); - mockDispatch.mockImplementation(() => { - // No-op - }); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter valid amount - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - expect(placeBetButton).toBeOnTheScreen(); - - // Verify it's not disabled by trying to press it - fireEvent.press(placeBetButton); - expect(mockPlaceOrder).toHaveBeenCalled(); - }); - - it('does not place bet when onPlaceBet called with insufficient funds', () => { - mockBalance = 50; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter valid amount first - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Now change to insufficient amount - const amountDisplay = getByText('10'); - fireEvent.press(amountDisplay); - - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); - - // Try to place bet (button would be disabled/hidden but let's verify logic) - // We can't actually test this directly since the button is replaced - // But the validation is tested through the button visibility - }); - - it('does not place bet when onPlaceBet called below minimum', () => { - mockBalance = 1000; - mockBalanceLoading = false; - // Reset mockDispatch to not throw - mockDispatch.mockClear(); - mockDispatch.mockImplementation(() => { - // No-op - }); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - // Enter valid amount first - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - const doneButton = getByText('Done'); fireEvent.press(doneButton); - // Verify button works with valid amount - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - fireEvent.press(placeBetButton); - - expect(mockPlaceOrder).toHaveBeenCalled(); + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); }); describe('rate limiting', () => { - it('button is enabled when rateLimited is undefined (backward compatibility)', () => { + it('renders place bet button when not rate limited', () => { mockExpectedAmount = 120; mockBalance = 1000; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter valid amount - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); - const doneButton = getByText('Done'); fireEvent.press(doneButton); - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - fireEvent.press(placeBetButton); - - // Button should work (backward compatibility) - expect(mockPlaceOrder).toHaveBeenCalled(); - }); - - it('place bet button works with sufficient funds when not rate limited', () => { - mockBalance = 1000; - mockBalanceLoading = false; - mockExpectedAmount = 120; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter valid amount - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // With default mocks (no rateLimited), button should work - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - fireEvent.press(placeBetButton); - expect(mockPlaceOrder).toHaveBeenCalled(); + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); }); describe('error message rendering', () => { - it('renders insufficient funds error with correct text', () => { + it('hides error messages on initial render with zero amount', () => { mockBalance = 50; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - act(() => { - capturedOnChange?.({ - value: '60', - valueAsNumber: 60, - }); - }); + renderWithProvider(, { state: initialState }); - // maxBetAmount = balance - (providerFee + metamaskFee) = 50 - 1.5 = 48.5 expect( - getByText('Not enough funds. You can use up to $48.50.'), - ).toBeOnTheScreen(); + screen.queryByText(/Not enough funds\. You can use up to/), + ).not.toBeOnTheScreen(); }); - it('renders minimum bet error with correct text', () => { + it('hides error messages when balance is sufficient', () => { mockBalance = 1000; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); - - // Press done - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - expect(getByText('Minimum amount is $1.00')).toBeOnTheScreen(); - }); + renderWithProvider(, { state: initialState }); - it('renders only one error at a time - insufficient funds takes priority', () => { - mockBalance = 0.3; - mockBalanceLoading = false; - - const { getByText, queryByText } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter amount below minimum AND exceeds balance - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); - - // Should show insufficient funds only - // maxBetAmount = balance - (providerFee + metamaskFee) = 0.3 - 1.5 = -1.2 expect( - getByText('Not enough funds. You can use up to $-1.20.'), - ).toBeOnTheScreen(); - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); - }); - - it('does not render error when no validation issues', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { queryByText } = renderWithProvider(, { - state: initialState, - }); - - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - + screen.queryByText(/Not enough funds\. You can use up to/), + ).not.toBeOnTheScreen(); expect( - queryByText(/Not enough funds\. You can use up to/), + screen.queryByText('Minimum amount is $1.00'), ).not.toBeOnTheScreen(); - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); }); }); describe('integration tests', () => { - it('user flow: enter amount > balance, see error, click add funds', () => { + it('displays available balance and quick action buttons', () => { mockBalance = 100; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - // Enter amount exceeding balance - act(() => { - capturedOnChange?.({ - value: '150', - valueAsNumber: 150, - }); - }); - - // Verify error shows - // maxBetAmount = balance - (providerFee + metamaskFee) = 100 - 1.5 = 98.5 - expect( - getByText('Not enough funds. You can use up to $98.50.'), - ).toBeOnTheScreen(); - - // Verify Add funds button shows - const addFundsButton = getByText('Add funds'); - expect(addFundsButton).toBeOnTheScreen(); - - // Click Add funds - fireEvent.press(addFundsButton); - expect(mockDeposit).toHaveBeenCalled(); + expect(screen.getByText(/Available:.*\$100\.00/)).toBeOnTheScreen(); + expect(screen.getByText('$20')).toBeOnTheScreen(); + expect(screen.getByText('$50')).toBeOnTheScreen(); + expect(screen.getByText('$100')).toBeOnTheScreen(); + expect(screen.getByText('Done')).toBeOnTheScreen(); }); - it('user flow: enter amount < $1, see minimum error, increase amount, error clears', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { getByText, queryByText } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter below minimum - act(() => { - capturedOnChange?.({ - value: '0.5', - valueAsNumber: 0.5, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Verify minimum error shows - expect(getByText('Minimum amount is $1.00')).toBeOnTheScreen(); - - // Increase amount - const amountDisplay = getByText('0.5'); - fireEvent.press(amountDisplay); - - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // Error should clear - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); - }); - - it('user flow: balance loads, then user enters amount, validation works', () => { - // Start with loading - mockBalanceLoading = true; - - const { getByTestId, rerender } = renderWithProvider( - , - { - state: initialState, - }, - ); - - expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); - - // Balance finishes loading - mockBalanceLoading = false; - mockBalance = 100; - - // Rerender to simulate state update - rerender(); - - // Now enter amount - act(() => { - capturedOnChange?.({ - value: '150', - valueAsNumber: 150, - }); - }); - - // Should show error now that balance is loaded - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - act(() => { - capturedOnChange?.({ - value: '150', - valueAsNumber: 150, - }); - }); - - // maxBetAmount = balance - (providerFee + metamaskFee) = 100 - 1.5 = 98.5 - expect( - getByText('Not enough funds. You can use up to $98.50.'), - ).toBeOnTheScreen(); - }); - - it('user flow: enter valid amount with sufficient funds, can place bet successfully', async () => { + it('dispatches pop action when place order result is successful', async () => { mockBalance = 1000; mockBalanceLoading = false; mockPlaceOrderResult = { success: true, response: { transactionHash: '0xabc123' }, }; - - const { getByText, rerender } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter valid amount - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Click place bet - const placeBetButton = getByText('Yes ¡ 50Âĸ'); - - await act(async () => { - fireEvent.press(placeBetButton); + const { rerender } = renderWithProvider(, { + state: initialState, }); - expect(mockPlaceOrder).toHaveBeenCalled(); - - // Rerender to trigger useEffect with result rerender(); expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); @@ -1948,142 +731,49 @@ describe('PredictBuyPreview', () => { }); describe('edge cases', () => { - it('handles balance exactly equal to total', () => { - mockBalance = 51.5; + it('hides error on initial render with low balance', () => { + mockBalance = 1.5; mockBalanceLoading = false; - const { queryByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - // Enter 50 (50 + 1.5 fees = 51.5) - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - // Should NOT show error expect( - queryByText(/Not enough funds\. You can use up to/), + screen.queryByText(/Not enough funds\. You can use up to/), ).not.toBeOnTheScreen(); }); - it('handles balance slightly less than total', () => { - mockBalance = 51.4; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter 50 (50 + 1.5 fees = 51.5 > 51.4) - act(() => { - capturedOnChange?.({ - value: '50', - valueAsNumber: 50, - }); - }); - - // Should show error - // maxBetAmount = balance - (providerFee + metamaskFee) = 51.4 - 1.5 = 49.9 - expect( - getByText('Not enough funds. You can use up to $49.90.'), - ).toBeOnTheScreen(); - }); - - it('handles amount exactly $1.00', () => { - mockBalance = 1000; + it('displays very low balance correctly', () => { + mockBalance = 1.4; mockBalanceLoading = false; - const { queryByText, getByText } = renderWithProvider( - , - { - state: initialState, - }, - ); - - // Enter exactly $1 - act(() => { - capturedOnChange?.({ - value: '1', - valueAsNumber: 1, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Should NOT show minimum error - expect(queryByText('Minimum amount is $1.00')).not.toBeOnTheScreen(); + renderWithProvider(, { state: initialState }); - // Should show place bet button - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText(/Available:.*\$1\.40/)).toBeOnTheScreen(); }); - it('handles amount $0.99', () => { - mockBalance = 1000; - mockBalanceLoading = false; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter $0.99 - act(() => { - capturedOnChange?.({ - value: '0.99', - valueAsNumber: 0.99, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // Should show minimum error - expect(getByText('Minimum amount is $1.00')).toBeOnTheScreen(); - }); - - it('handles very large balances', () => { + it('formats very large balance with commas and two decimals', () => { mockBalance = 999999999; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - expect(getByText('Available: $999,999,999.00')).toBeOnTheScreen(); + expect(screen.getByText('Available: $999,999,999.00')).toBeOnTheScreen(); }); - it('validates with fees included in total calculation', () => { + it('renders component with custom fees', () => { mockBalance = 100; mockBalanceLoading = false; mockMetamaskFee = 10; mockProviderFee = 20; - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - // Enter 75 (75 + 30 fees = 105 > 100) - act(() => { - capturedOnChange?.({ - value: '75', - valueAsNumber: 75, - }); - }); - - // Should show error - // maxBetAmount = balance - (providerFee + metamaskFee) = 100 - 30 = 70 - expect( - getByText('Not enough funds. You can use up to $70.00.'), - ).toBeOnTheScreen(); + expect(screen.getByText(/Available:.*\$100\.00/)).toBeOnTheScreen(); }); }); - describe('additional branch coverage', () => { - it('uses default entry point when entryPoint is undefined', () => { + describe('route parameter variations', () => { + it('renders component when entryPoint is undefined', () => { const routeWithoutEntryPoint = { ...mockRoute, params: { @@ -2091,18 +781,16 @@ describe('PredictBuyPreview', () => { entryPoint: undefined, }, }; - mockUseRoute.mockReturnValue(routeWithoutEntryPoint); - const { getByText } = renderWithProvider(, { - state: initialState, - }); + renderWithProvider(, { state: initialState }); - // Component renders successfully with default entry point - expect(getByText('Will Bitcoin reach $150,000?')).toBeOnTheScreen(); + expect( + screen.getByText('Will Bitcoin reach $150,000?'), + ).toBeOnTheScreen(); }); - it('handles missing groupItemTitle in outcome', () => { + it('renders outcome without group title when groupItemTitle is undefined', () => { const routeWithoutGroupTitle = { ...mockRoute, params: { @@ -2113,41 +801,14 @@ describe('PredictBuyPreview', () => { }, }, }; - mockUseRoute.mockReturnValue(routeWithoutGroupTitle); - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Component renders without group title prefix - expect(getByText('Yes at 50Âĸ')).toBeOnTheScreen(); - }); - - it('handles outcome with No token', () => { - const routeWithNoToken = { - ...mockRoute, - params: { - ...mockRoute.params, - outcomeToken: { - id: 'outcome-token-790', - title: 'No', - price: 0.6, - }, - }, - }; - - mockUseRoute.mockReturnValue(routeWithNoToken); + renderWithProvider(, { state: initialState }); - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Renders No token (uses preview sharePrice 0.5, not outcomeToken price) - expect(getByText('No at 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('Yes at 50Âĸ')).toBeOnTheScreen(); }); - it('applies error color styling for No token in place bet button', () => { + it('displays No outcome in place bet button when done is pressed', () => { const routeWithNoToken = { ...mockRoute, params: { @@ -2159,295 +820,83 @@ describe('PredictBuyPreview', () => { }, }, }; - mockUseRoute.mockReturnValue(routeWithNoToken); mockBalance = 1000; mockBalanceLoading = false; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter valid amount - act(() => { - capturedOnChange?.({ - value: '100', - valueAsNumber: 100, - }); - }); - - // Press done to show button - const doneButton = getByText('Done'); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); fireEvent.press(doneButton); - // No button rendered with error color styling - expect(getByText('No ¡ 50Âĸ')).toBeOnTheScreen(); - }); - - it('handles outcome token title that is neither Yes nor No', () => { - const routeWithCustomToken = { - ...mockRoute, - params: { - ...mockRoute.params, - outcomeToken: { - id: 'outcome-token-custom', - title: 'Maybe', - price: 0.75, - }, - }, - }; - - mockUseRoute.mockReturnValue(routeWithCustomToken); - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Renders custom token (uses preview sharePrice 0.5, not outcomeToken price) - expect(getByText('Maybe at 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('No ¡ 50Âĸ')).toBeOnTheScreen(); }); }); - describe('Rewards Calculation', () => { - it('passes totalFee to usePredictRewards hook', () => { + describe('rewards integration', () => { + it('renders component when rewards are enabled with estimated points', () => { mockMetamaskFee = 0.5; mockProviderFee = 1.0; mockRewardsEnabled = true; mockAccountOptedIn = true; mockEstimatedPoints = 50; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount to trigger preview calculation - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // Hook should be called with totalFee = 0.5 + 1.0 = 1.5 - // This is verified indirectly through props passed to PredictFeeSummary - const doneButton = getByText('Done'); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); fireEvent.press(doneButton); - // Verify rewards are displayed when enabled and account is opted in - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); - it('uses estimated points from usePredictRewards hook', () => { - mockMetamaskFee = 1.234; - mockProviderFee = 0; - mockRewardsEnabled = true; - mockAccountOptedIn = true; - mockEstimatedPoints = 123; - - renderWithProvider(, { - state: initialState, - }); - - // Hook returns estimated points directly - // Expected: 123 points from hook - }); - - it('handles zero points when estimatedPoints is 0', () => { + it('renders component when rewards are enabled with zero points', () => { mockMetamaskFee = 0; mockProviderFee = 0; mockRewardsEnabled = true; mockAccountOptedIn = true; mockEstimatedPoints = 0; - renderWithProvider(, { - state: initialState, - }); - - // Expected: 0 points from hook - }); - - it('updates when totalFee changes', () => { - mockMetamaskFee = 0.5; - mockProviderFee = 0.5; - mockRewardsEnabled = true; - mockAccountOptedIn = true; - mockEstimatedPoints = 50; - - const { rerender } = renderWithProvider(, { - state: initialState, - }); - - // Change fees - mockMetamaskFee = 1.0; - mockProviderFee = 1.0; - mockEstimatedPoints = 100; - - rerender(); + renderWithProvider(, { state: initialState }); - // Hook should be called with new totalFee = 2.0 + expect( + screen.getByText('Will Bitcoin reach $150,000?'), + ).toBeOnTheScreen(); }); - }); - describe('Rewards Display', () => { - it('shows rewards row when enabled, amount is entered, and accountOptedIn is not null', () => { - mockMetamaskFee = 0.5; - mockProviderFee = 1.0; + it('renders component when rewards loading state is true', () => { mockRewardsEnabled = true; mockAccountOptedIn = true; + mockRewardsLoading = true; mockEstimatedPoints = 50; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // Press done to show fee summary - const doneButton = getByText('Done'); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); fireEvent.press(doneButton); - // shouldShowRewardsRow = true when rewardsEnabled && currentValue > 0 && accountOptedIn != null - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); - it('does not show rewards when amount is zero', () => { + it('renders component when rewards error state is true', () => { mockRewardsEnabled = true; mockAccountOptedIn = true; - - renderWithProvider(, { - state: initialState, - }); - - // No amount entered (currentValue = 0) - // shouldShowRewardsRow = false when currentValue is 0 - }); - - it('does not show rewards when accountOptedIn is null', () => { - mockRewardsEnabled = true; - mockAccountOptedIn = null; + mockRewardsError = true; mockEstimatedPoints = null; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // shouldShowRewardsRow = false when accountOptedIn is null - const doneButton = getByText('Done'); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); fireEvent.press(doneButton); - // Rewards row should not be shown - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); - it('shows rewards when accountOptedIn is false (opt-in supported)', () => { + it('renders component when account is not opted in to rewards', () => { mockRewardsEnabled = true; mockAccountOptedIn = false; mockEstimatedPoints = null; - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // shouldShowRewardsRow = true when accountOptedIn is false (opt-in supported) - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); - }); - - it('passes isLoadingRewards including isRewardsLoading', () => { - mockRewardsEnabled = true; - mockAccountOptedIn = true; - mockRewardsLoading = true; - mockEstimatedPoints = 50; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // isLoadingRewards = (isCalculating && isUserInputChange) || isRewardsLoading - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); - }); - - it('passes hasRewardsError from hook', () => { - mockRewardsEnabled = true; - mockAccountOptedIn = true; - mockRewardsError = true; - mockEstimatedPoints = null; - - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Enter amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - const doneButton = getByText('Done'); - fireEvent.press(doneButton); - - // hasRewardsError should be passed to PredictFeeSummary - expect(getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); - }); - }); - - describe('Fee Breakdown Bottom Sheet', () => { - it('does not show bottom sheet initially', () => { - const { queryByTestId } = renderWithProvider(, { - state: initialState, - }); - - expect(queryByTestId('fee-breakdown-sheet')).toBeNull(); - }); - - it('opens bottom sheet when fees info is pressed', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); - - // Click Done to show fee summary - const doneButton = getByText('Done'); + renderWithProvider(, { state: initialState }); + const doneButton = screen.getByText('Done'); fireEvent.press(doneButton); - // Component should pass onFeesInfoPress callback to PredictFeeSummary - // which sets isFeeBreakdownVisible to true + expect(screen.getByText('Yes ¡ 50Âĸ')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx index dd9cadd1b73..3dff8ed59b4 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx @@ -3,7 +3,15 @@ import React from 'react'; import { PredictMarketListSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import PredictFeed from './PredictFeed'; -// Mock child components +/** + * Mock Strategy: + * - Only mock child components with complex dependencies and external services + * - Do NOT mock: Design system, theme utilities, SafeAreaView, Reanimated + * - Child components are mocked because they have their own test coverage + * and we're testing the parent's state management and component orchestration + */ + +// Mock child components - have their own test coverage jest.mock('../../components/PredictFeedHeader', () => { const { View, Pressable } = jest.requireActual('react-native'); return { @@ -51,28 +59,7 @@ jest.mock('../../components/PredictMarketList', () => { }; }); -// Mock hooks -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: jest.fn((...args) => args), - }), -})); - -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - default: '#FFFFFF', - }, - }, - }), -})); - -jest.mock('react-native-safe-area-context', () => ({ - SafeAreaView: jest.requireActual('react-native').View, - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), -})); - +// Mock navigation hooks jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useRoute: jest.fn(() => ({ @@ -83,17 +70,7 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn((callback) => callback()), })); -jest.mock('react-native-reanimated', () => { - const View = jest.requireActual('react-native').View; - return { - default: { - View, - }, - useAnimatedStyle: jest.fn(() => ({})), - useSharedValue: jest.fn((val) => ({ value: val })), - }; -}); - +// Mock session manager - external analytics service jest.mock('../../services/PredictFeedSessionManager', () => { const mockInstance = { startSession: jest.fn(), @@ -112,6 +89,7 @@ jest.mock('../../services/PredictFeedSessionManager', () => { }; }); +// Mock shared scroll coordinator - complex shared state management jest.mock('../../hooks/useSharedScrollCoordinator', () => ({ useSharedScrollCoordinator: jest.fn(() => ({ balanceCardOffset: { value: 0 }, @@ -136,399 +114,121 @@ describe('PredictFeed', () => { }); describe('initial render', () => { - it('renders container with correct testID', () => { - // Arrange & Act + it('displays container with feed header, balance, and market list', () => { const { getByTestId } = render(); - // Assert expect( getByTestId(PredictMarketListSelectorsIDs.CONTAINER), ).toBeOnTheScreen(); - }); - - it('renders PredictFeedHeader component', () => { - // Arrange & Act - const { getByTestId } = render(); - - // Assert expect(getByTestId('predict-feed-header-mock')).toBeOnTheScreen(); - }); - - it('renders PredictBalance component when search is not visible', () => { - // Arrange & Act - const { getByTestId } = render(); - - // Assert expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - }); - - it('renders PredictMarketList component', () => { - // Arrange & Act - const { getByTestId } = render(); - - // Assert expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); }); - it('initializes with search not visible', () => { - // Arrange & Act + it('starts with search hidden and empty query', () => { const { queryByTestId } = render(); - // Assert expect(queryByTestId('search-visible-indicator')).toBeNull(); - }); - - it('initializes with empty search query', () => { - // Arrange & Act - const { queryByTestId } = render(); - - // Assert expect(queryByTestId('market-list-query')).toBeNull(); }); }); describe('search toggle functionality', () => { - it('shows search when toggle is pressed', () => { - // Arrange - const { getByTestId } = render(); - - // Act - const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - - // Assert - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - }); - - it('hides PredictBalance when search is visible', () => { - // Arrange + it('shows search and hides balance when toggle pressed', () => { const { getByTestId, queryByTestId } = render(); - - // Act const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - // Assert - expect(queryByTestId('predict-balance-mock')).toBeNull(); - }); - - it('passes isSearchVisible true to PredictMarketList when search is toggled', () => { - // Arrange - const { getByTestId } = render(); - - // Act - const toggleButton = getByTestId('mock-search-toggle'); fireEvent.press(toggleButton); - // Assert - expect(getByTestId('market-list-search-mode')).toBeOnTheScreen(); - }); - - it('passes isSearchVisible true to PredictFeedHeader when search is toggled', () => { - // Arrange - const { getByTestId } = render(); - - // Act - const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - - // Assert expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); + expect(queryByTestId('predict-balance-mock')).toBeNull(); + expect(getByTestId('market-list-search-mode')).toBeOnTheScreen(); }); }); describe('search cancel functionality', () => { - it('hides search when cancel is pressed', () => { - // Arrange + it('hides search, shows balance, and clears query when cancel pressed', () => { const { getByTestId, queryByTestId } = render(); const toggleButton = getByTestId('mock-search-toggle'); + const searchInput = getByTestId('mock-search-input'); fireEvent.press(toggleButton); + fireEvent.press(searchInput); expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); + expect(getByTestId('market-list-query')).toBeOnTheScreen(); - // Act const cancelButton = getByTestId('mock-search-cancel'); fireEvent.press(cancelButton); - // Assert expect(queryByTestId('search-visible-indicator')).toBeNull(); - }); - - it('shows PredictBalance when search is cancelled', () => { - // Arrange - const { getByTestId } = render(); - const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - - // Act - const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); - - // Assert expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - }); - - it('clears search query when cancel is pressed', () => { - // Arrange - const { getByTestId, queryByTestId } = render(); - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); - expect(getByTestId('market-list-query')).toBeOnTheScreen(); - - // Act - const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); - - // Assert - expect(queryByTestId('market-list-query')).toBeNull(); - }); - - it('passes empty query to PredictMarketList after cancel', () => { - // Arrange - const { getByTestId, queryByTestId } = render(); - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); - - // Act - const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); - - // Assert expect(queryByTestId('market-list-query')).toBeNull(); }); }); describe('search functionality', () => { - it('updates search query when search is performed', () => { - // Arrange + it('updates and displays search query in market list', () => { const { getByTestId, getByText } = render(); - - // Act const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); - // Assert - expect(getByText('test query')).toBeOnTheScreen(); - }); - - it('passes search query to PredictMarketList', () => { - // Arrange - const { getByTestId } = render(); - - // Act - const searchInput = getByTestId('mock-search-input'); fireEvent.press(searchInput); - // Assert - expect(getByTestId('market-list-query')).toBeOnTheScreen(); - }); - - it('keeps search query when toggling search visibility', () => { - // Arrange - const { getByTestId, getByText } = render(); - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); expect(getByText('test query')).toBeOnTheScreen(); - - // Act - toggle off and on - const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); - const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - - // Assert - query was cleared by cancel - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); + expect(getByTestId('market-list-query')).toBeOnTheScreen(); }); }); - describe('component integration', () => { - it('handles complete search workflow', () => { - // Arrange + describe('complete search workflow', () => { + it('executes full search cycle from toggle to cancel with all state changes', () => { const { getByTestId, getByText, queryByTestId } = render(); - - // Act 1 - toggle search on const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); + const searchInput = getByTestId('mock-search-input'); + const cancelButton = getByTestId('mock-search-cancel'); - // Assert 1 - search is visible, balance is hidden + fireEvent.press(toggleButton); expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); expect(queryByTestId('predict-balance-mock')).toBeNull(); - // Act 2 - perform search - const searchInput = getByTestId('mock-search-input'); fireEvent.press(searchInput); - - // Assert 2 - query is displayed expect(getByText('test query')).toBeOnTheScreen(); - // Act 3 - cancel search - const cancelButton = getByTestId('mock-search-cancel'); fireEvent.press(cancelButton); - - // Assert 3 - search is hidden, balance is shown, query is cleared expect(queryByTestId('search-visible-indicator')).toBeNull(); expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); expect(queryByTestId('market-list-query')).toBeNull(); }); - it('maintains PredictMarketList visibility throughout search workflow', () => { - // Arrange + it('keeps market list visible throughout entire search workflow', () => { const { getByTestId } = render(); + const marketList = getByTestId('predict-market-list-mock'); - // Assert - always visible initially - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); + expect(marketList).toBeOnTheScreen(); - // Act 1 - toggle search - const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); + fireEvent.press(getByTestId('mock-search-toggle')); + expect(marketList).toBeOnTheScreen(); - // Assert - still visible - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); - - // Act 2 - search - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); - - // Assert - still visible - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); + fireEvent.press(getByTestId('mock-search-input')); + expect(marketList).toBeOnTheScreen(); - // Act 3 - cancel - const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); - - // Assert - still visible - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); + fireEvent.press(getByTestId('mock-search-cancel')); + expect(marketList).toBeOnTheScreen(); }); - }); - - describe('state management', () => { - it('manages independent state for search visibility and query', () => { - // Arrange - const { getByTestId, getByText, queryByTestId } = render(); - // Act - set query without toggling search - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); - - // Assert - query is set but search not visible - expect(getByText('test query')).toBeOnTheScreen(); - expect(queryByTestId('search-visible-indicator')).toBeNull(); - }); - - it('toggles search visibility multiple times', () => { - // Arrange + it('toggles search visibility multiple times independently of query state', () => { const { getByTestId, queryByTestId } = render(); - - // Act & Assert - toggle on const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - - // Act & Assert - toggle off const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); - expect(queryByTestId('search-visible-indicator')).toBeNull(); - // Act & Assert - toggle on again fireEvent.press(toggleButton); expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - // Act & Assert - toggle off again fireEvent.press(cancelButton); expect(queryByTestId('search-visible-indicator')).toBeNull(); - }); - }); - describe('conditional rendering', () => { - it('conditionally renders PredictBalance based on search visibility', () => { - // Arrange - const { getByTestId, queryByTestId } = render(); - - // Assert - visible initially - expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - - // Act - show search - const toggleButton = getByTestId('mock-search-toggle'); fireEvent.press(toggleButton); + expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - // Assert - hidden when search visible - expect(queryByTestId('predict-balance-mock')).toBeNull(); - - // Act - hide search - const cancelButton = getByTestId('mock-search-cancel'); fireEvent.press(cancelButton); - - // Assert - visible again - expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - }); - }); - - describe('props propagation', () => { - it('passes all required props to PredictFeedHeader', () => { - // Arrange & Act - const { getByTestId } = render(); - - // Assert - component renders, meaning all props were provided - expect(getByTestId('predict-feed-header-mock')).toBeOnTheScreen(); - }); - - it('passes all required props to PredictMarketList', () => { - // Arrange & Act - const { getByTestId } = render(); - - // Assert - component renders, meaning all props were provided - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); - }); - - it('updates PredictFeedHeader isSearchVisible prop', () => { - // Arrange - const { getByTestId, queryByTestId } = render(); expect(queryByTestId('search-visible-indicator')).toBeNull(); - - // Act - const toggleButton = getByTestId('mock-search-toggle'); - fireEvent.press(toggleButton); - - // Assert - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - }); - - it('updates PredictMarketList searchQuery prop', () => { - // Arrange - const { getByTestId, queryByTestId, getByText } = render(); - expect(queryByTestId('market-list-query')).toBeNull(); - - // Act - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(searchInput); - - // Assert - expect(getByText('test query')).toBeOnTheScreen(); - }); - }); - - describe('layout structure', () => { - it('renders SafeAreaView as root container', () => { - // Arrange & Act - const { getByTestId } = render(); - - // Assert - expect( - getByTestId(PredictMarketListSelectorsIDs.CONTAINER), - ).toBeOnTheScreen(); - }); - - it('renders all components in correct order', () => { - // Arrange & Act - const { getByTestId, toJSON } = render(); - - // Assert - all components present - expect(getByTestId('predict-feed-header-mock')).toBeOnTheScreen(); - expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); - - // Verify structure exists - const tree = toJSON(); - expect(tree).toBeTruthy(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index a18edfd388a..4fe6b0195ea 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -55,79 +55,62 @@ jest.mock('@react-navigation/stack', () => ({ }), })); -jest.mock('@metamask/design-system-react-native', () => { +jest.mock('react-native-safe-area-context', () => { const { View } = jest.requireActual('react-native'); return { - Box: View, - BoxFlexDirection: { - Row: 'row', - Column: 'column', - }, - BoxAlignItems: { - Center: 'center', - }, - BoxJustifyContent: { - Between: 'space-between', - }, - ButtonSize: { - Lg: 'lg', - Md: 'md', - Sm: 'sm', - }, + SafeAreaView: View, + SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + })), + useSafeAreaFrame: () => ({ x: 0, y: 0, width: 375, height: 812 }), }; }); -const mockUseTheme = jest.fn(() => ({ - colors: { - background: { - default: '#ffffff', - }, - text: { - default: '#121314', - muted: '#666666', - }, - icon: { - default: '#121314', - }, - primary: { - default: '#037DD6', - }, - success: { - default: '#28A745', - }, - error: { - default: '#DC3545', +// Minimal mock to add testID pattern for icon assertions +jest.mock('../../../../../component-library/components/Icons/Icon', () => { + const ActualIcon = jest.requireActual( + '../../../../../component-library/components/Icons/Icon', + ); + return { + ...ActualIcon, + __esModule: true, + default: ({ + name, + testID, + ...props + }: { + name: string; + testID?: string; + [key: string]: unknown; + }) => { + const Icon = ActualIcon.default; + return ; }, - }, -})); - -jest.mock('../../../../../util/theme', () => ({ - useTheme: mockUseTheme, -})); + }; +}); -jest.mock('react-native-safe-area-context', () => { - const { View } = jest.requireActual('react-native'); +// Minimal mock to add testID pattern for button assertions +jest.mock('../../../../../component-library/components/Buttons/Button', () => { + const ActualButton = jest.requireActual( + '../../../../../component-library/components/Buttons/Button', + ); return { - SafeAreaView: ({ - children, - style, + ...ActualButton, + __esModule: true, + default: ({ testID, + ...props }: { - children: React.ReactNode; - style?: React.ComponentProps['style']; testID?: string; - }) => ( - - {children} - - ), - useSafeAreaInsets: jest.fn(() => ({ - top: 0, - right: 0, - bottom: 0, - left: 0, - })), - SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, + [key: string]: unknown; + }) => { + const Button = ActualButton.default; + return