diff --git a/.github/workflows/auto-rc-ota-build-core.yml b/.github/workflows/auto-rc-ota-build-core.yml new file mode 100644 index 00000000000..c847996c55a --- /dev/null +++ b/.github/workflows/auto-rc-ota-build-core.yml @@ -0,0 +1,145 @@ +############################################################################################## +# +# Auto RC OTA / build core (reusable) +# +# Shared logic for the Auto RC flow (build-rc-auto.yml): detect an OTA_VERSION bump and either +# dispatch push-eas-update.yml, or fall through to build.yml. +# +# Runway's manual entry workflows no longer use this file — they call the dedicated OTA-only or +# build-only workflows (runway-ota-*.yml, runway-*-builds.yml) directly. Kept here to preserve +# automatic OTA-vs-build detection on every push to a release branch. +# +############################################################################################## +name: Auto RC OTA Build Core + +on: + workflow_call: + inputs: + platform: + description: 'Target platform passed to push-eas-update and build.yml (android or ios)' + required: true + type: string + source_branch: + description: >- + Optional branch, tag, or SHA (Build workflow source_branch). + Empty uses the branch selected in the caller workflow_dispatch "Use workflow from" UI. + required: false + type: string + default: '' + ota_channel: + description: 'push-eas-update channel input (e.g. rc, production)' + required: false + type: string + default: rc + build_name: + description: 'build.yml build_name (e.g. main-rc, main-prod)' + required: false + type: string + default: main-rc + create_production_ota_tag: + description: 'If true, create OTA release tag after production trigger-ota (callers: *production* only)' + required: false + type: boolean + default: false + environment: + description: 'Build environment / track passed to upload-to-testflight (e.g. rc, prod)' + required: false + type: string + default: 'rc' + skip_version_bump: + description: >- + If true, build.yml skips update-latest-build-version. Auto-RC callers set true since the + bump is performed once upstream. + required: false + type: boolean + default: false + outputs: + semantic_version: + description: 'package.json version at the built commit (empty when OTA path taken)' + value: ${{ jobs.trigger-build.outputs.semantic_version }} + ios_version_code: + description: 'iOS CURRENT_PROJECT_VERSION at the built commit (empty when OTA path taken)' + value: ${{ jobs.trigger-build.outputs.ios_version_code }} + android_version_code: + description: 'Android versionCode at the built commit (empty when OTA path taken)' + value: ${{ jobs.trigger-build.outputs.android_version_code }} + +permissions: + contents: write # required by build.yml (update-build-version job) + pull-requests: read + actions: write + id-token: write # required by build.yml + +jobs: + resolve-context: + name: Resolve OTA context + uses: ./.github/workflows/runway-ota-resolve-context.yml + with: + source_branch: ${{ inputs.source_branch }} + secrets: inherit + + validate-ota-pr: + name: Validate PR for OTA + needs: resolve-context + if: needs.resolve-context.outputs.ota_bump == 'true' + runs-on: ubuntu-latest + steps: + - name: Validate PR number + run: | + if [[ -z "${{ needs.resolve-context.outputs.pr_number }}" ]]; then + echo "::error::No PR found for this branch. OTA update requires a PR number." + echo "::error::If you ran the workflow manually (workflow_dispatch), select your release branch in the 'Use workflow from' dropdown (e.g. release/7.71.0), not main." + exit 1 + fi + echo "Using PR #${{ needs.resolve-context.outputs.pr_number }}" + + trigger-ota: + name: Trigger OTA update + needs: [resolve-context, validate-ota-pr] + if: needs.resolve-context.outputs.ota_bump == 'true' + uses: ./.github/workflows/push-eas-update.yml + with: + pr_number: ${{ needs.resolve-context.outputs.pr_number }} + base_branch: ${{ needs.resolve-context.outputs.base_ref }} + message: ${{ needs.resolve-context.outputs.ota_version }} + channel: ${{ inputs.ota_channel }} + platform: ${{ inputs.platform }} + secrets: inherit + + trigger-build: + name: Trigger build mobile app + needs: resolve-context + if: needs.resolve-context.outputs.ota_bump != 'true' + uses: ./.github/workflows/build.yml + with: + build_name: ${{ inputs.build_name }} + platform: ${{ inputs.platform }} + skip_version_bump: ${{ inputs.skip_version_bump }} + source_branch: ${{ inputs.source_branch || github.ref_name }} + upload_to_sentry: true + secrets: inherit + + create-ota-production-tag: + name: Create OTA production release tag + needs: [resolve-context, trigger-ota] + if: ${{ inputs.create_production_ota_tag == true }} + uses: ./.github/workflows/runway-create-ota-production-tag.yml + with: + tag_name: ${{ needs.resolve-context.outputs.ota_version }} + checkout_ref: ${{ inputs.source_branch || github.ref_name }} + secrets: inherit + + upload-ios-testflight: + name: Upload iOS to TestFlight + needs: [trigger-build] + if: ${{ inputs.platform == 'ios' }} + uses: ./.github/workflows/upload-to-testflight.yml + with: + environment: ${{ inputs.environment }} + source_branch: ${{ inputs.source_branch || github.ref_name }} + build_branch: ${{ inputs.source_branch || github.ref_name }} + build_name: ${{ inputs.build_name }} + build_commit_sha: ${{ needs.trigger-build.outputs.built_commit_sha }} + build_version: ${{ needs.trigger-build.outputs.semantic_version }} + build_number: ${{ needs.trigger-build.outputs.ios_version_code }} + secrets: inherit diff --git a/.github/workflows/build-rc-auto.yml b/.github/workflows/build-rc-auto.yml index 95a6dba90f1..4d79f0bb1c1 100644 --- a/.github/workflows/build-rc-auto.yml +++ b/.github/workflows/build-rc-auto.yml @@ -8,7 +8,7 @@ # Bitrise "Rolling builds" / "Abort running builds" for one branch + one workflow). # # Version bump runs once (update-latest-build-version.yml), then iOS and Android -# builds are triggered in parallel via runway-ota-build-core.yml (skip_version_bump). +# builds are triggered in parallel via auto-rc-ota-build-core.yml (skip_version_bump). # # The RC build comment includes an AI-generated test plan (inline with collapsible sections). # @@ -104,7 +104,7 @@ jobs: trigger-ios-rc-build: name: Trigger iOS RC Build - uses: ./.github/workflows/runway-ota-build-core.yml + uses: ./.github/workflows/auto-rc-ota-build-core.yml needs: - validate-and-find-pr - update_rc_build_version @@ -117,7 +117,7 @@ jobs: trigger-android-rc-build: name: Trigger Android RC Build - uses: ./.github/workflows/runway-ota-build-core.yml + uses: ./.github/workflows/auto-rc-ota-build-core.yml needs: - validate-and-find-pr - update_rc_build_version diff --git a/.github/workflows/runway-android-production-workflow.yml b/.github/workflows/runway-android-production-workflow.yml deleted file mode 100644 index fdac5745de2..00000000000 --- a/.github/workflows/runway-android-production-workflow.yml +++ /dev/null @@ -1,41 +0,0 @@ -############################################################################################## -# -# Runway Android Production Workflow -# -# Triggered from Runway to either: -# - Push an OTA update to the production channel (when OTA_VERSION is bumped), or -# - Build the production mobile app (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the correct branch (e.g. main or release). -# Version bump: skipped — run Runway iOS Production first on the same branch for the bump. -# -############################################################################################## -name: Runway Android Production - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-production: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: android - source_branch: ${{ inputs.source_branch }} - ota_channel: production - build_name: main-prod - create_production_ota_tag: true - skip_version_bump: true - secrets: inherit diff --git a/.github/workflows/runway-android-rc-workflow.yml b/.github/workflows/runway-android-rc-workflow.yml deleted file mode 100644 index b30426089e1..00000000000 --- a/.github/workflows/runway-android-rc-workflow.yml +++ /dev/null @@ -1,50 +0,0 @@ -############################################################################################## -# -# Runway Android RC Workflow -# -# Triggered from Runway to either: -# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts is bumped), or -# - Build the mobile app (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). -# Version bump: skipped here — run Runway iOS RC first on the same branch so it performs the bump. -# -############################################################################################## -name: Runway Android RC - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-rc: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: android - source_branch: ${{ inputs.source_branch }} - skip_version_bump: true - secrets: inherit - - slack-notification: - name: Slack RC Notification - needs: runway-rc - if: success() - uses: ./.github/workflows/slack-rc-notification.yml - with: - source_branch: ${{ inputs.source_branch || github.ref_name }} - semver: ${{ needs.runway-rc.outputs.semantic_version }} - ios_build_number: ${{ needs.runway-rc.outputs.ios_version_code }} - android_build_number: ${{ needs.runway-rc.outputs.android_version_code }} - secrets: inherit diff --git a/.github/workflows/runway-ios-production-workflow.yml b/.github/workflows/runway-ios-production-workflow.yml deleted file mode 100644 index 118b768fe0d..00000000000 --- a/.github/workflows/runway-ios-production-workflow.yml +++ /dev/null @@ -1,41 +0,0 @@ -############################################################################################## -# -# Runway iOS Production Workflow -# -# Triggered from Runway to either: -# - Push an OTA update to the production channel (when OTA_VERSION is bumped), or -# - Build the production app and upload the IPA to TestFlight (when there is no OTA bump). -# -# When triggering workflow_dispatch, select the correct branch (e.g. main or release). -# Version bump: this workflow bumps; run Android production after on the same branch. -# -############################################################################################## -name: Runway iOS Production - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-production: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: ios - source_branch: ${{ inputs.source_branch }} - ota_channel: production - build_name: main-prod - create_production_ota_tag: true - environment: prod - secrets: inherit diff --git a/.github/workflows/runway-ios-rc-workflow.yml b/.github/workflows/runway-ios-rc-workflow.yml deleted file mode 100644 index 6f3dfab2e1d..00000000000 --- a/.github/workflows/runway-ios-rc-workflow.yml +++ /dev/null @@ -1,49 +0,0 @@ -############################################################################################## -# -# Runway iOS RC Workflow -# -# Triggered from Runway to either: -# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts is bumped), or -# - Build the mobile app and upload the IPA to TestFlight (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). -# Version bump: this workflow bumps the repo build number; run Android RC after so it can skip bump. -# -############################################################################################## -name: Runway iOS RC - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-rc: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: ios - source_branch: ${{ inputs.source_branch }} - secrets: inherit - - slack-notification: - name: Slack RC Notification - needs: runway-rc - if: success() - uses: ./.github/workflows/slack-rc-notification.yml - with: - source_branch: ${{ inputs.source_branch || github.ref_name }} - semver: ${{ needs.runway-rc.outputs.semantic_version }} - ios_build_number: ${{ needs.runway-rc.outputs.ios_version_code }} - android_build_number: ${{ needs.runway-rc.outputs.android_version_code }} - secrets: inherit diff --git a/.github/workflows/runway-ota-build-core.yml b/.github/workflows/runway-ota-build-core.yml deleted file mode 100644 index 43d23d6a92c..00000000000 --- a/.github/workflows/runway-ota-build-core.yml +++ /dev/null @@ -1,225 +0,0 @@ -############################################################################################## -# -# Runway OTA / build core (reusable) -# -# Shared logic for Runway: OTA bump detection, push-eas-update dispatch, or build.yml. -# Callers: iOS Runway workflows bump once (skip_version_bump false); Android pass skip_version_bump true. -# -############################################################################################## -name: Runway OTA Build Core - -on: - workflow_call: - inputs: - platform: - description: 'Target platform passed to push-eas-update and build.yml (android or ios)' - required: true - type: string - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the caller workflow_dispatch "Use workflow from" UI. - required: false - type: string - default: '' - ota_channel: - description: 'push-eas-update channel input (e.g. rc, production)' - required: false - type: string - default: rc - build_name: - description: 'build.yml build_name (e.g. main-rc, main-prod)' - required: false - type: string - default: main-rc - create_production_ota_tag: - description: 'If true, create OTA release tag after production trigger-ota (callers: *production* only)' - required: false - type: boolean - default: false - environment: - description: 'Build environment / track passed to upload-to-testflight (e.g. rc, prod)' - required: false - type: string - default: 'rc' - skip_version_bump: - description: >- - If true, build.yml skips update-latest-build-version. Android Runway entry workflows set - true (iOS bumps first); iOS uses default false. - required: false - type: boolean - default: false - outputs: - semantic_version: - description: 'package.json version at the built commit (empty when OTA path taken)' - value: ${{ jobs.trigger-build.outputs.semantic_version }} - ios_version_code: - description: 'iOS CURRENT_PROJECT_VERSION at the built commit (empty when OTA path taken)' - value: ${{ jobs.trigger-build.outputs.ios_version_code }} - android_version_code: - description: 'Android versionCode at the built commit (empty when OTA path taken)' - value: ${{ jobs.trigger-build.outputs.android_version_code }} - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - decide: - name: Check OTA version and resolve inputs - runs-on: ubuntu-latest - outputs: - ota_bump: ${{ steps.decide.outputs.ota_bump }} - base_ref: ${{ steps.decide.outputs.base_ref }} - ota_version: ${{ steps.decide.outputs.ota_version }} - pr_number: ${{ steps.resolve-pr.outputs.pr_number }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.source_branch || github.ref }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Resolve PR number for current branch - id: resolve-pr - run: | - BRANCH="${{ inputs.source_branch || github.ref_name }}" - # Strip refs/heads/ if present - BRANCH="${BRANCH#refs/heads/}" - echo "Resolving PR for branch: $BRANCH (repo: $GITHUB_REPOSITORY)" - - # Try same-repo head first, then owner:branch (required by API when listing pulls) - # jq '.[0].number' on an empty array outputs the literal string "null", so normalise to empty - PR_NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --json number --jq '.[0].number // empty' 2>/dev/null || echo "") - if [[ -z "$PR_NUMBER" ]]; then - PR_NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REPOSITORY_OWNER:$BRANCH" --json number --jq '.[0].number // empty' 2>/dev/null || echo "") - fi - - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "Branch: $BRANCH, PR number: ${PR_NUMBER:-none}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Decide OTA vs build - id: decide - run: | - set -e - # Version from package.json (e.g. 7.70.0) → base ref for OTA workflow is always v{VERSION} - VERSION=$(node -p "require('./package.json').version") - RELEASE_TAG="v${VERSION}" - echo "base_ref=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - - # Parse OTA_VERSION from the export line (do not use a fixed line number — comment block length changes). - extract_ota() { grep -E '^export const OTA_VERSION' "$1" | sed -n "s/^export const OTA_VERSION: string = '\\([^']*\\)'.*/\\1/p"; } - extract_ota_from_git_show() { grep -E '^export const OTA_VERSION' | sed -n "s/^export const OTA_VERSION: string = '\\([^']*\\)'.*/\\1/p"; } - - # OTA_VERSION from current ref - CURRENT_OTA=$(extract_ota app/constants/ota.ts) - echo "ota_version=${CURRENT_OTA}" >> "$GITHUB_OUTPUT" - - # Early exit 1: sentinel means no OTA has been configured for this release - if [[ "$CURRENT_OTA" == "vX.XX.X" ]]; then - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "OTA_VERSION is sentinel ($CURRENT_OTA) → will trigger build" - exit 0 - fi - - # Early exit 2: if a tag for this OTA_VERSION already exists, the OTA was - # already shipped (e.g. merged from a prior release branch) — treat as stale. - if git rev-parse "refs/tags/${CURRENT_OTA}" >/dev/null 2>&1; then - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "OTA tag ${CURRENT_OTA} already exists (already shipped) → stale, will trigger build" - exit 0 - fi - - # Ref to compare against for detecting bump: use release tag if it exists, else main - if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then - COMPARE_REF="$RELEASE_TAG" - BASE_OTA=$(git show "${COMPARE_REF}:app/constants/ota.ts" 2>/dev/null | extract_ota_from_git_show || echo "") - else - COMPARE_REF="main" - BASE_OTA=$(git show "origin/main:app/constants/ota.ts" 2>/dev/null | extract_ota_from_git_show || echo "") - echo "Release tag ${RELEASE_TAG} not found; comparing OTA_VERSION to ${COMPARE_REF} to detect bump" - fi - - if [[ -n "$BASE_OTA" && "$CURRENT_OTA" != "$BASE_OTA" ]]; then - echo "ota_bump=true" >> "$GITHUB_OUTPUT" - echo "OTA_VERSION changed: $BASE_OTA -> $CURRENT_OTA → will trigger OTA update" - else - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "No OTA version bump (base: $BASE_OTA, current: $CURRENT_OTA) → will trigger build" - fi - - # Reusable workflows must be job-level `uses:` (not a step). Steps only support actions (action.yml). - validate-ota-pr: - name: Validate PR for OTA - needs: decide - if: needs.decide.outputs.ota_bump == 'true' - runs-on: ubuntu-latest - steps: - - name: Validate PR number - run: | - if [[ -z "${{ needs.decide.outputs.pr_number }}" ]]; then - echo "::error::No PR found for this branch. OTA update requires a PR number." - echo "::error::If you ran the workflow manually (workflow_dispatch), select your release branch in the 'Use workflow from' dropdown (e.g. release/7.71.0), not main." - exit 1 - fi - echo "Using PR #${{ needs.decide.outputs.pr_number }}" - - trigger-ota: - name: Trigger OTA update - needs: [decide, validate-ota-pr] - if: needs.decide.outputs.ota_bump == 'true' - uses: ./.github/workflows/push-eas-update.yml - with: - pr_number: ${{ needs.decide.outputs.pr_number }} - base_branch: ${{ needs.decide.outputs.base_ref }} - message: ${{ needs.decide.outputs.ota_version }} - channel: ${{ inputs.ota_channel }} - platform: ${{ inputs.platform }} - secrets: inherit - - trigger-build: - name: Trigger build mobile app - needs: decide - if: needs.decide.outputs.ota_bump != 'true' - uses: ./.github/workflows/build.yml - with: - build_name: ${{ inputs.build_name }} - platform: ${{ inputs.platform }} - skip_version_bump: ${{ inputs.skip_version_bump }} - source_branch: ${{ inputs.source_branch || github.ref_name }} - upload_to_sentry: true - secrets: inherit - - create-ota-production-tag: - name: Create OTA production release tag - needs: [decide, trigger-ota] - if: ${{ inputs.create_production_ota_tag == true }} - uses: ./.github/workflows/runway-create-ota-production-tag.yml - with: - tag_name: ${{ needs.decide.outputs.ota_version }} - checkout_ref: ${{ inputs.source_branch || github.ref_name }} - secrets: inherit - - upload-ios-testflight: - name: Upload iOS to TestFlight - needs: [decide, trigger-build] - if: ${{ inputs.platform == 'ios' }} - uses: ./.github/workflows/upload-to-testflight.yml - with: - environment: ${{ inputs.environment }} - source_branch: ${{ inputs.source_branch || github.ref_name }} - build_branch: ${{ inputs.source_branch || github.ref_name }} - build_name: ${{ inputs.build_name }} - build_commit_sha: ${{ needs.trigger-build.outputs.built_commit_sha }} - build_version: ${{ needs.trigger-build.outputs.semantic_version }} - build_number: ${{ needs.trigger-build.outputs.ios_version_code }} - secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dba34e05c..17e9fc6db66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.75.1] + +### Fixed + +- Fixed Hyperliquid withdraw showing $0 and being blocked for users on Unified Account mode. (#29492) + ## [7.75.0] ### Added @@ -11365,7 +11371,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.1...HEAD +[7.75.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...v7.75.1 [7.75.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.3...v7.75.0 [7.74.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.2...v7.74.3 [7.74.2]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.1...v7.74.2 diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts deleted file mode 100644 index 2aadf982816..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable no-console */ -import { ImageSourcePropType } from 'react-native'; - -const imageSource = - 'https://assets.coingecko.com/coins/images/279/small/ethereum.png?1595348880'; - -export const CONTRACT_PET_NAME = 'DAI'; -export const CONTRACT_BOX_TEST_ID = 'contract-box'; -export const CONTRACT_LOCAL_IMAGE: ImageSourcePropType = { - uri: imageSource, -}; - -export const CONTRACT_COPY_ADDRESS = () => { - console.log('copy address'); -}; - -export const CONTRACT_EXPORT_ADDRESS = () => { - console.log('export address'); -}; - -export const CONTRACT_ON_PRESS = () => { - console.log('contract pressed'); -}; - -export const HAS_BLOCK_EXPLORER = true; -export const TOKEN_SYMBOL = 'D'; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts deleted file mode 100644 index 46ff512773f..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; - -/** - * Style sheet for Account Balance component. - * - * @returns StyleSheet object. - */ -const styleSheet = StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - }, -}); - -export default styleSheet; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx deleted file mode 100644 index d4acbe3aea9..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import TEST_ADDRESS from '../../../../constants/address'; -import ContractBox from './ContractBox'; -import { - CONTRACT_BOX_TEST_ID, - CONTRACT_PET_NAME, - CONTRACT_LOCAL_IMAGE, - CONTRACT_COPY_ADDRESS, - CONTRACT_EXPORT_ADDRESS, - CONTRACT_ON_PRESS, -} from './ContractBox.constants'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; - -describe('ContractBox', () => { - it('should render ContractBox', () => { - renderWithProvider( - , - { - state: { - engine: { - backgroundState: { - PreferencesController: { isIpfsGatewayEnabled: true }, - }, - }, - }, - }, - ); - expect(screen.getAllByTestId(CONTRACT_BOX_TEST_ID).length).toBeGreaterThan( - 0, - ); - }); -}); diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx deleted file mode 100644 index f06f4e6a176..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import Card from '../../../components/Cards/Card'; -import ContractBoxBase from '../ContractBoxBase'; -import styles from './ContractBox.styles'; -import { View } from 'react-native'; -import { ContractBoxProps } from './ContractBox.types'; -import { CONTRACT_BOX_TEST_ID } from './ContractBox.constants'; - -const ContractBox = ({ - contractAddress, - contractPetName, - contractLocalImage, - onExportAddress, - onCopyAddress, - onContractPress, - hasBlockExplorer, -}: ContractBoxProps) => ( - - - - - -); - -export default ContractBox; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts deleted file mode 100644 index c7e39064c39..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ContractBoxBaseProps } from '../ContractBoxBase/ContractBoxBase.types'; - -export type ContractBoxProps = ContractBoxBaseProps; diff --git a/app/component-library/components-temp/Contracts/ContractBox/index.ts b/app/component-library/components-temp/Contracts/ContractBox/index.ts deleted file mode 100644 index bd87bb9fbfb..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ContractBox'; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts deleted file mode 100644 index 23a62de58a3..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const EXPORT_ICON_TEST_ID = 'export-icon'; -export const COPY_ICON_TEST_ID = 'copy-icon'; -export const CONTRACT_BOX_TEST_ID = 'contract-box'; -export const CONTRACT_BOX_NO_PET_NAME_TEST_ID = 'contract-box-no-pet-name'; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts deleted file mode 100644 index 8d164eb6a7a..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../util/theme/models'; -/** - * Style sheet for Account Balance component. - * - * @returns StyleSheet object. - */ -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - return StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - flex: 1, - }, - rowContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - imageContainer: { - marginRight: 16, - }, - icon: { - paddingHorizontal: 6, - }, - iconContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - header: { - color: theme.colors.info.default, - }, - }); -}; - -export default styleSheet; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx deleted file mode 100644 index 58e1e9532d3..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import ContractBoxBase from './ContractBoxBase'; -import TEST_ADDRESS from '../../../../constants/address'; -import { - CONTRACT_PET_NAME, - CONTRACT_LOCAL_IMAGE, - CONTRACT_COPY_ADDRESS, - CONTRACT_ON_PRESS, -} from '../ContractBox/ContractBox.constants'; -import { CONTRACT_BOX_NO_PET_NAME_TEST_ID } from './ContractBoxBase.constants'; -import { ContractBoxBaseProps } from './ContractBoxBase.types'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; - -describe('Component ContractBoxBase', () => { - let props: ContractBoxBaseProps; - - beforeEach(() => { - props = { - contractAddress: TEST_ADDRESS, - contractPetName: CONTRACT_PET_NAME, - contractLocalImage: CONTRACT_LOCAL_IMAGE, - onCopyAddress: CONTRACT_COPY_ADDRESS, - onContractPress: CONTRACT_ON_PRESS, - }; - }); - - const renderComponent = () => - renderWithProvider(, { - state: { - engine: { - backgroundState: { - PreferencesController: { isIpfsGatewayEnabled: true }, - }, - }, - }, - }); - - it('should render correctly', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toBeDefined(); - }); - - it('renders the no-pet-name element when contractPetName is undefined', () => { - props.contractPetName = undefined; - renderComponent(); - expect(screen.getByTestId(CONTRACT_BOX_NO_PET_NAME_TEST_ID)).toBeTruthy(); - }); -}); diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx deleted file mode 100644 index 5ba78777e38..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// Third party depencies -import React from 'react'; -import { View, Pressable } from 'react-native'; - -// External dependencies. -import Avatar, { - AvatarSize, - AvatarVariant, -} from '../../../components/Avatars/Avatar'; -import Text, { TextVariant } from '../../../components/Texts/Text'; -import { formatAddress } from '../../../../util/address'; -import Icon, { IconName, IconSize } from '../../../components/Icons/Icon'; -import { useStyles } from '../../../hooks'; -import Button, { ButtonVariants } from '../../../components/Buttons/Button'; -import Identicon from '../../../../components/UI/Identicon'; - -// Internal dependencies. -import { ContractBoxBaseProps, IconViewProps } from './ContractBoxBase.types'; -import styleSheet from './ContractBoxBase.styles'; -import { - EXPORT_ICON_TEST_ID, - COPY_ICON_TEST_ID, - CONTRACT_BOX_TEST_ID, - CONTRACT_BOX_NO_PET_NAME_TEST_ID, -} from './ContractBoxBase.constants'; - -const ContractBoxBase = ({ - contractAddress, - contractLocalImage, - contractPetName, - onCopyAddress, - onExportAddress, - onContractPress, - hasBlockExplorer, -}: ContractBoxBaseProps) => { - const formattedAddress = formatAddress(contractAddress, 'short'); - const { - styles, - theme: { colors }, - } = useStyles(styleSheet, {}); - - const renderIconView = ({ onPress, name, size, testID }: IconViewProps) => ( - - - - ); - - return ( - - - - {contractLocalImage ? ( - - ) : ( - - )} - - {contractPetName ? ( - - - {contractPetName} - - {formattedAddress} - - ) : ( - - + + ); }; diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx index 9917f800bce..6f2633ce026 100644 --- a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx @@ -197,7 +197,7 @@ describe('AssetOverviewClaimBonus', () => { ).not.toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$30.00'); + ).toHaveTextContent('$30.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.LIFETIME_VALUE), ).toHaveTextContent('+$221.59'); @@ -239,7 +239,7 @@ describe('AssetOverviewClaimBonus', () => { ).toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$15.00'); + ).toHaveTextContent('$15.00'); }); }); @@ -279,7 +279,7 @@ describe('AssetOverviewClaimBonus', () => { ).not.toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); }); }); @@ -318,7 +318,7 @@ describe('AssetOverviewClaimBonus', () => { ).toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.LIFETIME_VALUE), ).toHaveTextContent('$0.00'); @@ -560,7 +560,7 @@ describe('AssetOverviewClaimBonus', () => { // (700 + 300) * 3% = 30.00 expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$30.00'); + ).toHaveTextContent('$30.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Claim $5.00 bonus'); @@ -586,7 +586,7 @@ describe('AssetOverviewClaimBonus', () => { // 500 * 3% = 15.00, "Accruing next bonus" because balance > 0 & no claim expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$15.00'); + ).toHaveTextContent('$15.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Accruing next bonus'); @@ -614,7 +614,7 @@ describe('AssetOverviewClaimBonus', () => { // on Linea and always returned undefined, dropping Linea balances. expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$6.00'); + ).toHaveTextContent('$6.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Accruing next bonus'); @@ -642,7 +642,7 @@ describe('AssetOverviewClaimBonus', () => { expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('No accruing bonus'); @@ -683,7 +683,7 @@ describe('AssetOverviewClaimBonus', () => { expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$4.50'); + ).toHaveTextContent('$4.50'); }); it('looks up mUSD on each chain using checksummed addresses', () => { diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx index 67903b1e659..be7bddd408a 100644 --- a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx @@ -129,8 +129,8 @@ const AssetOverviewClaimBonus: React.FC = ({ [balance], ); const formattedAnnualBonus = hasBalance - ? `+$${estimatedAnnualBonus.toFixed(2)}` - : '+$0.00'; + ? `$${estimatedAnnualBonus.toFixed(2)}` + : '$0.00'; // Lifetime bonus: white $0.00 until first claim, then green +$X. const hasLifetimeBonus = Number(lifetimeBonusClaimed) > 0; @@ -356,7 +356,7 @@ const AssetOverviewClaimBonus: React.FC = ({ {/* CTA Button */} - ) : ( - - )} - - ); -}; - -export default MoneyAccountHomeRow; diff --git a/app/components/UI/Money/components/MoneyAccountHomeRow/index.ts b/app/components/UI/Money/components/MoneyAccountHomeRow/index.ts deleted file mode 100644 index 86e35c1e823..00000000000 --- a/app/components/UI/Money/components/MoneyAccountHomeRow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './MoneyAccountHomeRow'; -export { MoneyAccountHomeRowTestIds } from './MoneyAccountHomeRow.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts new file mode 100644 index 00000000000..016c8f5dece --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts @@ -0,0 +1,13 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + height: 82, + borderRadius: 12, + paddingHorizontal: 16, + marginHorizontal: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx new file mode 100644 index 00000000000..cce3b643534 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx @@ -0,0 +1,367 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyBalanceCard from './MoneyBalanceCard'; +import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import { selectMusdConversionEducationSeen } from '../../../../../reducers/user/selectors'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +jest.mock('../../hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../../reducers/user/selectors', () => ({ + __esModule: true, + selectMusdConversionEducationSeen: jest.fn(), +})); + +const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); +const mockSelectMusdConversionEducationSeen = jest.mocked( + selectMusdConversionEducationSeen, +); + +const createBalanceMock = ( + overrides: Partial> = {}, +) => + ({ + totalFiatFormatted: '$1,000.00', + totalFiatRaw: '1000', + tokenTotal: undefined, + isAggregatedBalanceLoading: false, + apyDecimal: 0.04, + apyPercent: 4, + apyPercentFormatted: '4%', + vaultApyQuery: { + data: { apy: 0.04, timestamp: '2026-01-01T00:00:00Z' }, + isLoading: false, + }, + musdBalanceQuery: { + data: { balance: '1000000000' }, + isLoading: false, + }, + musdEquivalentBalanceQuery: { + data: { + musdEquivalentValue: '0', + musdSHFvdBalance: '0', + exchangeRate: '1000000', + }, + isLoading: false, + }, + musdFiatFormatted: '$1,000.00', + musdSHFvdFiatFormatted: '$0.00', + ...overrides, + }) as ReturnType; + +describe('MoneyBalanceCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMoneyAccountBalance.mockReturnValue(createBalanceMock()); + mockSelectMusdConversionEducationSeen.mockReturnValue(true); + }); + + describe('when balance is empty (totalFiatRaw undefined)', () => { + beforeEach(() => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: undefined, + totalFiatFormatted: undefined, + }), + ); + }); + + it('renders the empty container testID', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.EMPTY_CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the balance as $0.00', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$0.00', + ); + }); + + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); + }); + + it('renders the label', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.LABEL)).toHaveTextContent( + strings('money.balance_card.label'), + ); + }); + + it('renders the empty container when totalFiatRaw is the string zero', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ totalFiatRaw: '0', totalFiatFormatted: '$0.00' }), + ); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.EMPTY_CONTAINER), + ).toBeOnTheScreen(); + }); + }); + + describe('when balance is empty and onboarding has not been seen', () => { + beforeEach(() => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: undefined, + totalFiatFormatted: undefined, + }), + ); + mockSelectMusdConversionEducationSeen.mockReturnValue(false); + }); + + it('renders the new-user container testID', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.NEW_USER_CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the Get started button', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), + ).toHaveTextContent( + strings('homepage.sections.money_empty_state.get_started'), + ); + }); + + it('does not render the Add button', () => { + const { queryByTestId } = renderWithProvider(); + + expect( + queryByTestId(MoneyBalanceCardTestIds.ADD_BUTTON), + ).not.toBeOnTheScreen(); + }); + + it('navigates to the conversion education flow when Get started is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.EARN.ROOT, { + screen: Routes.EARN.MUSD.CONVERSION_EDUCATION, + params: { + returnTo: { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }, + }, + }); + }); + }); + + describe('when balance is funded', () => { + it('renders the funded container testID', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.FUNDED_CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the balance from useMoneyAccountBalance', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$1,000.00', + ); + }); + + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); + }); + + it('renders the APY tag', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toHaveTextContent( + strings('money.apy_label', { percentage: 4 }), + ); + }); + + it('falls back to $0.00 when totalFiatFormatted is undefined but totalFiatRaw is non-zero', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: '1000', + totalFiatFormatted: undefined, + }), + ); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$0.00', + ); + }); + }); + + describe('navigation', () => { + it('navigates to MONEY home when the card body is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.FUNDED_CONTAINER)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.ROOT, { + screen: Routes.MONEY.HOME, + }); + }); + + it('opens the Add money sheet when Add is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }); + + it('opens the Money balance info sheet when the info icon is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.INFO_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.MONEY_BALANCE_INFO_SHEET, + }); + }); + + it('opens the Add money sheet (and not the Money home) when Add is pressed in empty state', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: undefined, + totalFiatFormatted: undefined, + }), + ); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }); + }); + + describe('loading states', () => { + it('renders balance skeleton when balance is loading', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ isAggregatedBalanceLoading: true }), + ); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(MoneyBalanceCardTestIds.BALANCE_SKELETON), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.BALANCE), + ).not.toBeOnTheScreen(); + }); + + it('renders APY skeleton when APY is loading', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + vaultApyQuery: { + data: undefined, + isLoading: true, + } as ReturnType['vaultApyQuery'], + }), + ); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(MoneyBalanceCardTestIds.APY_TAG_SKELETON), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.APY_TAG), + ).not.toBeOnTheScreen(); + }); + + it('renders balance and APY values when data has loaded', () => { + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toBeOnTheScreen(); + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.BALANCE_SKELETON), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.APY_TAG_SKELETON), + ).not.toBeOnTheScreen(); + }); + + it('renders the APY tag with 0 when apyPercent is undefined', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ apyPercent: undefined }), + ); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toHaveTextContent( + strings('money.apy_label', { percentage: 0 }), + ); + }); + }); + + describe('layout resilience', () => { + it('keeps the APY tag and Add button on screen when the balance is very long', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: '999999999990', + totalFiatFormatted: '$999,999,999,999.99', + }), + ); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$999,999,999,999.99', + ); + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toBeOnTheScreen(); + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts new file mode 100644 index 00000000000..6c9a3dac516 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts @@ -0,0 +1,13 @@ +export const MoneyBalanceCardTestIds = { + NEW_USER_CONTAINER: 'money-balance-card-new-user-container', + EMPTY_CONTAINER: 'money-balance-card-empty-container', + FUNDED_CONTAINER: 'money-balance-card-funded-container', + LABEL: 'money-balance-card-label', + INFO_BUTTON: 'money-balance-card-info-button', + BALANCE: 'money-balance-card-balance', + BALANCE_SKELETON: 'money-balance-card-balance-skeleton', + APY_TAG: 'money-balance-card-apy-tag', + APY_TAG_SKELETON: 'money-balance-card-apy-tag-skeleton', + ADD_BUTTON: 'money-balance-card-add-button', + GET_STARTED_BUTTON: 'money-balance-card-get-started-button', +} as const; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx new file mode 100644 index 00000000000..18719ab448d --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx @@ -0,0 +1,204 @@ +import React, { useCallback } from 'react'; +import { Pressable } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonIcon, + ButtonIconSize, + ButtonSize, + ButtonVariant, + FontWeight, + IconColor, + IconName, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useStyles } from '../../../../../component-library/hooks'; +import { selectMusdConversionEducationSeen } from '../../../../../reducers/user/selectors'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import styleSheet from './MoneyBalanceCard.styles'; +import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; + +const EMPTY_BALANCE_DISPLAY = '$0.00'; + +const MoneyBalanceCard = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + const { + totalFiatRaw, + totalFiatFormatted, + apyPercent, + isAggregatedBalanceLoading, + vaultApyQuery, + } = useMoneyAccountBalance(); + const hasSeenOnboarding = useSelector(selectMusdConversionEducationSeen); + + const isEmpty = totalFiatRaw === undefined || totalFiatRaw === '0'; + const isNewUser = isEmpty && !hasSeenOnboarding; + + let balanceText: string; + let buttonVariant: ButtonVariant; + let buttonLabel: string; + let buttonTestId: string; + let containerTestId: string; + if (isNewUser) { + balanceText = EMPTY_BALANCE_DISPLAY; + buttonVariant = ButtonVariant.Primary; + buttonLabel = strings('homepage.sections.money_empty_state.get_started'); + buttonTestId = MoneyBalanceCardTestIds.GET_STARTED_BUTTON; + containerTestId = MoneyBalanceCardTestIds.NEW_USER_CONTAINER; + } else if (isEmpty) { + balanceText = EMPTY_BALANCE_DISPLAY; + buttonVariant = ButtonVariant.Primary; + buttonLabel = strings('money.balance_card.add'); + buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; + containerTestId = MoneyBalanceCardTestIds.EMPTY_CONTAINER; + } else { + balanceText = totalFiatFormatted ?? EMPTY_BALANCE_DISPLAY; + buttonVariant = ButtonVariant.Secondary; + buttonLabel = strings('money.balance_card.add'); + buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; + containerTestId = MoneyBalanceCardTestIds.FUNDED_CONTAINER; + } + + const handleCardPress = useCallback(() => { + navigation.navigate(Routes.MONEY.ROOT, { + screen: Routes.MONEY.HOME, + }); + }, [navigation]); + + const handleAddPress = useCallback(() => { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }, [navigation]); + + const handleGetStartedPress = useCallback(() => { + navigation.navigate(Routes.EARN.ROOT, { + screen: Routes.EARN.MUSD.CONVERSION_EDUCATION, + params: { + returnTo: { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }, + }, + }); + }, [navigation]); + + const handleButtonPress = isNewUser ? handleGetStartedPress : handleAddPress; + + const handleInfoPress = useCallback(() => { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.MONEY_BALANCE_INFO_SHEET, + }); + }, [navigation]); + + return ( + [ + styles.container, + tw.style( + 'flex-row items-center justify-between bg-muted', + pressed && 'opacity-80', + ), + ]} + > + + + + {strings('money.balance_card.label')} + + + + + {isAggregatedBalanceLoading ? ( + + ) : ( + + {balanceText} + + )} + {vaultApyQuery.isLoading ? ( + + ) : ( + + + {strings('money.apy_label', { percentage: apyPercent ?? 0 })} + + + )} + + + + + + + ); +}; + +export default MoneyBalanceCard; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/index.ts b/app/components/UI/Money/components/MoneyBalanceCard/index.ts new file mode 100644 index 00000000000..f9961f83ad5 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyBalanceCard'; +export { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts new file mode 100644 index 00000000000..83a15787e29 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => + StyleSheet.create({ + content: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 16, + backgroundColor: params.theme.colors.background.default, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx new file mode 100644 index 00000000000..1ce76bb0d7a --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyBalanceInfoSheet from './MoneyBalanceInfoSheet'; +import { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; +import { strings } from '../../../../../../locales/i18n'; + +const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); + + const MockBottomSheet = ReactActual.forwardRef( + ( + { + children, + testID, + goBack, + }: { + children: React.ReactNode; + testID?: string; + goBack?: () => void; + }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + onOpenBottomSheet: jest.fn(), + })); + return ReactActual.createElement( + View, + { testID }, + ReactActual.createElement( + Pressable, + { + testID: 'bottom-sheet-go-back', + onPress: goBack, + }, + ReactActual.createElement(RNText, {}, 'go-back'), + ), + children, + ); + }, + ); + + const MockBottomSheetHeader = ({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'bottom-sheet-header' }, + ReactActual.createElement( + Pressable, + { testID: 'bottom-sheet-close-button', onPress: onClose }, + ReactActual.createElement(RNText, {}, 'close'), + ), + children, + ); + + return { + ...actual, + BottomSheet: MockBottomSheet, + BottomSheetHeader: MockBottomSheetHeader, + }; +}); + +describe('MoneyBalanceInfoSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the container', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceInfoSheetTestIds.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the sheet title', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceInfoSheetTestIds.TITLE)).toHaveTextContent( + strings('money.balance_card.info_sheet_title'), + ); + }); + + it('renders the body copy', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceInfoSheetTestIds.BODY)).toHaveTextContent( + strings('money.balance_card.info_sheet_body'), + ); + }); + + it('closes the sheet when the close button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-close-button')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('navigates back when the BottomSheet goBack handler is invoked', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-go-back')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts new file mode 100644 index 00000000000..11f47f9297d --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts @@ -0,0 +1,5 @@ +export const MoneyBalanceInfoSheetTestIds = { + CONTAINER: 'money-balance-info-sheet-container', + TITLE: 'money-balance-info-sheet-title', + BODY: 'money-balance-info-sheet-body', +} as const; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx new file mode 100644 index 00000000000..3b3ec1a9841 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx @@ -0,0 +1,56 @@ +import React, { useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + BottomSheet, + BottomSheetHeader, + type BottomSheetRef, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './MoneyBalanceInfoSheet.styles'; +import { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; + +const MoneyBalanceInfoSheet = () => { + const sheetRef = useRef(null); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + + const handleGoBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('money.balance_card.info_sheet_title')} + + + + + {strings('money.balance_card.info_sheet_body')} + + + + ); +}; + +export default MoneyBalanceInfoSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts new file mode 100644 index 00000000000..9bc437212a2 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyBalanceInfoSheet'; +export { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx index 183b8878016..bb8f27d72fa 100644 --- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx +++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx @@ -9,6 +9,8 @@ import { IconColor, IconName, Skeleton, + Tag, + TagSeverity, Text, TextColor, TextVariant, @@ -51,7 +53,7 @@ const MoneyBalanceSummary = ({ fontWeight={FontWeight.Bold} testID={MoneyBalanceSummaryTestIds.TITLE} > - {strings('money.title')} + {strings('money.your_balance')} @@ -87,19 +89,19 @@ const MoneyBalanceSummary = ({ /> ) : ( isPositiveNumber(apy) && ( - {strings('money.apy_label', { percentage: apy })} - + ) )} {onApyInfoPress && isPositiveNumber(apy) && !isLoading && ( diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx index 5555345c311..b6d6d933ce7 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx @@ -11,6 +11,14 @@ import { selectHasInFlightMusdConversion, selectMusdConversionStatuses, } from '../../../Earn/selectors/musdConversionStatus'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../../Earn/constants/events/musdEvents'; +import { MONEY_EVENTS_CONSTANTS } from '../../constants/moneyEvents'; + +const { EVENT_LOCATIONS: MUSD_EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; +const { EVENT_LOCATIONS: MONEY_EVENT_LOCATIONS } = MONEY_EVENTS_CONSTANTS; + +const TEST_LOCATION = MONEY_EVENT_LOCATIONS.MONEY_HUB; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -19,6 +27,43 @@ jest.mock('react-redux', () => ({ const mockUseSelector = useSelector as jest.Mock; +const mockUseMusdConversionTokens = jest.fn(); +const mockInitiateMaxConversion = jest.fn(); +const mockInitiateCustomConversion = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(() => ({ event: 'built' })); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + +mockAddProperties.mockReturnValue({ + build: mockBuild, +}); + +jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ + useMusdConversionTokens: () => mockUseMusdConversionTokens(), +})); + +jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ + useMusdConversion: () => ({ + initiateMaxConversion: mockInitiateMaxConversion, + initiateCustomConversion: mockInitiateCustomConversion, + }), +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('../../../Earn/utils/network', () => ({ + getNetworkName: jest.fn(() => 'Ethereum Mainnet'), +})); + jest.mock('../../../../../component-library/base-components/TagBase', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => { @@ -103,28 +148,24 @@ const MOCK_DAI: AssetType = { const mockTokens: AssetType[] = [MOCK_USDC, MOCK_USDT, MOCK_DAI]; -const defaultProps = { - tokens: mockTokens, - onMaxPress: jest.fn(), - onEditPress: jest.fn(), - onLearnMorePress: jest.fn(), -}; - describe('MoneyConvertStablecoins', () => { beforeEach(() => { jest.clearAllMocks(); + mockAddProperties.mockReturnValue({ build: mockBuild }); + mockBuild.mockReturnValue({ event: 'built' }); mockUseSelector.mockImplementation((selector) => { if (selector === selectHasUnapprovedMusdConversion) return false; if (selector === selectHasInFlightMusdConversion) return false; if (selector === selectMusdConversionStatuses) return {}; return undefined; }); + mockUseMusdConversionTokens.mockReturnValue({ tokens: mockTokens }); }); describe('with eligible tokens', () => { it('renders the container', () => { const { getByTestId } = render( - , + , ); expect( @@ -134,7 +175,7 @@ describe('MoneyConvertStablecoins', () => { it('renders the convert title', () => { const { getByText } = render( - , + , ); expect( @@ -144,7 +185,7 @@ describe('MoneyConvertStablecoins', () => { it('renders the description with bonus text', () => { const { getByTestId } = render( - , + , ); expect( @@ -154,7 +195,7 @@ describe('MoneyConvertStablecoins', () => { it('renders feature tags', () => { const { getByTestId } = render( - , + , ); expect( @@ -164,7 +205,7 @@ describe('MoneyConvertStablecoins', () => { it('renders a MusdConversionAssetRow for each token', () => { const { getAllByTestId } = render( - , + , ); const rows = getAllByTestId(MusdConversionAssetRowTestIds.CONTAINER); @@ -173,7 +214,7 @@ describe('MoneyConvertStablecoins', () => { it('renders token symbols', () => { const { getByText } = render( - , + , ); expect(getByText('USDC')).toBeOnTheScreen(); @@ -181,36 +222,9 @@ describe('MoneyConvertStablecoins', () => { expect(getByText('DAI')).toBeOnTheScreen(); }); - it('renders the Learn more button', () => { - const { getByTestId } = render( - , - ); - - expect( - getByTestId(MoneyConvertStablecoinsTestIds.LEARN_MORE_CTA), - ).toBeOnTheScreen(); - }); - - it('calls onLearnMorePress when Learn more is pressed', () => { - const mockLearnMore = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press( - getByTestId(MoneyConvertStablecoinsTestIds.LEARN_MORE_CTA), - ); - - expect(mockLearnMore).toHaveBeenCalledTimes(1); - }); - - it('calls onMaxPress with token when Max is pressed', () => { - const mockMaxPress = jest.fn(); + it('initiates max conversion and tracks event with location when Max is pressed', () => { const { getAllByTestId } = render( - , + , ); const maxButtons = getAllByTestId( @@ -218,16 +232,24 @@ describe('MoneyConvertStablecoins', () => { ); fireEvent.press(maxButtons[0]); - expect(mockMaxPress).toHaveBeenCalledWith(MOCK_USDC); + expect(mockInitiateMaxConversion).toHaveBeenCalledWith(MOCK_USDC); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: TEST_LOCATION, + button_action: 'max', + asset_symbol: 'USDC', + redirects_to: + MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + }), + ); }); - it('calls onEditPress with token when Edit is pressed', () => { - const mockEditPress = jest.fn(); + it('initiates custom conversion and tracks event with location when Edit is pressed', () => { const { getAllByTestId } = render( - , + , ); const editButtons = getAllByTestId( @@ -235,19 +257,33 @@ describe('MoneyConvertStablecoins', () => { ); fireEvent.press(editButtons[1]); - expect(mockEditPress).toHaveBeenCalledWith(MOCK_USDT); + expect(mockInitiateCustomConversion).toHaveBeenCalledWith( + expect.objectContaining({ + preferredPaymentToken: { + address: MOCK_USDT.address, + chainId: MOCK_USDT.chainId, + }, + }), + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: TEST_LOCATION, + button_action: 'custom', + asset_symbol: 'USDT', + redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + }), + ); }); }); describe('without eligible tokens', () => { - const infoProps = { - ...defaultProps, - tokens: [], - }; + beforeEach(() => { + mockUseMusdConversionTokens.mockReturnValue({ tokens: [] }); + }); it('renders the container', () => { const { getByTestId } = render( - , + , ); expect( @@ -256,7 +292,9 @@ describe('MoneyConvertStablecoins', () => { }); it('renders the convert title', () => { - const { getByText } = render(); + const { getByText } = render( + , + ); expect( getByText(strings('money.convert_stablecoins.title')), @@ -265,7 +303,7 @@ describe('MoneyConvertStablecoins', () => { it('renders stacked token icons', () => { const { getByTestId } = render( - , + , ); expect( @@ -275,7 +313,7 @@ describe('MoneyConvertStablecoins', () => { it('renders the description', () => { const { getByTestId } = render( - , + , ); expect( @@ -285,7 +323,7 @@ describe('MoneyConvertStablecoins', () => { it('renders feature tags', () => { const { getByTestId } = render( - , + , ); expect( @@ -295,20 +333,10 @@ describe('MoneyConvertStablecoins', () => { it('does not render token rows', () => { const { queryByTestId } = render( - , + , ); expect(queryByTestId(MusdConversionAssetRowTestIds.CONTAINER)).toBeNull(); }); - - it('renders Learn more button', () => { - const { getByTestId } = render( - , - ); - - expect( - getByTestId(MoneyConvertStablecoinsTestIds.LEARN_MORE_CTA), - ).toBeOnTheScreen(); - }); }); }); diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts index 007ab9a71db..61496089ba5 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts @@ -2,6 +2,5 @@ export const MoneyConvertStablecoinsTestIds = { CONTAINER: 'money-convert-stablecoins-container', DESCRIPTION: 'money-convert-stablecoins-description', FEATURE_TAGS: 'money-convert-stablecoins-feature-tags', - LEARN_MORE_CTA: 'money-convert-stablecoins-learn-more-cta', TOKEN_ICONS: 'money-convert-stablecoins-token-icons', } as const; diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx index 795ec526fc5..536269ec90e 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx @@ -1,10 +1,7 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Box, BoxFlexDirection, - Button, - ButtonSize, - ButtonVariant, FontWeight, Icon, IconColor, @@ -29,7 +26,7 @@ import { buildTokenIconUrl } from '../../../Card/util/buildTokenIconUrl'; import MusdConversionAssetRow from '../../../Earn/components/Musd/MusdConversionAssetRow'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { MoneyConvertStablecoinsTestIds } from './MoneyConvertStablecoins.testIds'; -import { CaipChainId } from '@metamask/utils'; +import { CaipChainId, Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { createTokenChainKey, @@ -37,12 +34,18 @@ import { selectHasUnapprovedMusdConversion, selectMusdConversionStatuses, } from '../../../Earn/selectors/musdConversionStatus'; +import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; +import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../../Earn/constants/events/musdEvents'; +import { getNetworkName } from '../../../Earn/utils/network'; +import Logger from '../../../../../util/Logger'; + +const { EVENT_LOCATIONS: MUSD_EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; interface MoneyConvertStablecoinsProps { - tokens: AssetType[]; - onMaxPress: (token: AssetType) => void; - onEditPress: (token: AssetType) => void; - onLearnMorePress: () => void; + location: string; } const FEATURE_TAGS = [ @@ -134,13 +137,86 @@ const Description = () => ( ); const MoneyConvertStablecoins = ({ - tokens, - onMaxPress, - onEditPress, - onLearnMorePress, + location, }: MoneyConvertStablecoinsProps) => { + const { tokens } = useMusdConversionTokens(); + const { initiateMaxConversion, initiateCustomConversion } = + useMusdConversion(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const hasTokens = tokens.length > 0; + const handleMaxPress = useCallback( + async (token: AssetType) => { + try { + trackEvent( + createEventBuilder( + MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, + ) + .addProperties({ + location, + button_type: 'text_button', + button_action: 'max', + button_text: strings('earn.musd_conversion.max'), + redirects_to: + MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: token.chainId + ? getNetworkName(token.chainId as Hex) + : 'unknown', + }) + .build(), + ); + await initiateMaxConversion(token); + } catch (error) { + Logger.error(error as Error, { + message: + '[MoneyConvertStablecoins] Failed to initiate max conversion', + }); + } + }, + [createEventBuilder, initiateMaxConversion, location, trackEvent], + ); + + const handleEditPress = useCallback( + async (token: AssetType) => { + try { + trackEvent( + createEventBuilder( + MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, + ) + .addProperties({ + location, + button_type: 'icon_button', + icon: IconName.Edit, + button_action: 'custom', + redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: token.chainId + ? getNetworkName(token.chainId as Hex) + : 'unknown', + }) + .build(), + ); + + await initiateCustomConversion({ + preferredPaymentToken: { + address: token.address as Hex, + chainId: token.chainId as Hex, + }, + }); + } catch (error) { + Logger.error(error as Error, { + message: + '[MoneyConvertStablecoins] Failed to initiate custom conversion', + }); + } + }, + [createEventBuilder, initiateCustomConversion, location, trackEvent], + ); + const hasUnapprovedMusdConversion = useSelector( selectHasUnapprovedMusdConversion, ); @@ -196,8 +272,8 @@ const MoneyConvertStablecoins = ({ )} - - - - ); }; diff --git a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.styles.ts b/app/components/UI/Money/components/MoneyFooter/MoneyFooter.styles.ts deleted file mode 100644 index b56bbae502d..00000000000 --- a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.styles.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { StyleSheet } from 'react-native'; - -const createStyles = (bottom: number) => - StyleSheet.create({ - container: { - paddingBottom: Math.max(bottom, 16), - }, - }); - -export default createStyles; diff --git a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx b/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx index 2733cbcffee..22d48ff7ba4 100644 --- a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx +++ b/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx @@ -1,5 +1,4 @@ -import React, { useMemo } from 'react'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React from 'react'; import { Box, Button, @@ -8,7 +7,6 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { MoneyFooterTestIds } from './MoneyFooter.testIds'; -import createStyles from './MoneyFooter.styles'; interface MoneyFooterProps { onAddMoneyPress?: () => void; @@ -16,27 +14,18 @@ interface MoneyFooterProps { const MoneyFooter = ({ onAddMoneyPress = () => undefined, -}: MoneyFooterProps) => { - const { bottom } = useSafeAreaInsets(); - const styles = useMemo(() => createStyles(bottom), [bottom]); - - return ( - ( + + - - ); -}; + {strings('money.footer.add_money')} + + +); export default MoneyFooter; diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx index e6957fb1603..2b7a7ebcc97 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx @@ -2,34 +2,27 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import MoneyHeader from './MoneyHeader'; import { MoneyHeaderTestIds } from './MoneyHeader.testIds'; - -const noop = jest.fn(); +import { strings } from '../../../../../../locales/i18n'; describe('MoneyHeader', () => { - it('renders the back and menu buttons', () => { - const { getByTestId } = render( - , - ); + it('renders the menu button', () => { + const { getByTestId } = render(); - expect(getByTestId(MoneyHeaderTestIds.BACK_BUTTON)).toBeOnTheScreen(); expect(getByTestId(MoneyHeaderTestIds.MENU_BUTTON)).toBeOnTheScreen(); }); - it('calls onBackPress when the back button is pressed', () => { - const mockOnBackPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(MoneyHeaderTestIds.BACK_BUTTON)); + it('renders the Money title alongside the menu button', () => { + const { getByTestId } = render(); - expect(mockOnBackPress).toHaveBeenCalledTimes(1); + expect(getByTestId(MoneyHeaderTestIds.TITLE)).toHaveTextContent( + strings('money.title'), + ); }); it('calls onMenuPress when the menu button is pressed', () => { const mockOnMenuPress = jest.fn(); const { getByTestId } = render( - , + , ); fireEvent.press(getByTestId(MoneyHeaderTestIds.MENU_BUTTON)); diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts index 29abd63b1cd..e41dc9605c5 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts @@ -1,5 +1,5 @@ export const MoneyHeaderTestIds = { CONTAINER: 'money-header-container', - BACK_BUTTON: 'money-header-back-button', + TITLE: 'money-header-title', MENU_BUTTON: 'money-header-menu-button', } as const; diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx index da1cb440f54..f63fd31d16c 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx @@ -1,26 +1,25 @@ import React from 'react'; -import { HeaderStandard, IconName } from '@metamask/design-system-react-native'; +import { + HeaderBase, + HeaderBaseVariant, + IconName, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; import { MoneyHeaderTestIds } from './MoneyHeader.testIds'; interface MoneyHeaderProps { - /** - * Handler for the back/navigation button - */ - onBackPress: () => void; /** * Handler for the options menu button */ onMenuPress: () => void; } -const MoneyHeader = ({ onBackPress, onMenuPress }: MoneyHeaderProps) => ( - ( + ( testID: MoneyHeaderTestIds.MENU_BUTTON, }, ]} - /> + > + {strings('money.title')} + ); export default MoneyHeader; diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.test.tsx b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.test.tsx new file mode 100644 index 00000000000..2e80be9fb97 --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyMusdEmptyBalanceRow from './MoneyMusdEmptyBalanceRow'; +import { MoneyMusdEmptyBalanceRowTestIds } from './MoneyMusdEmptyBalanceRow.testIds'; + +describe('MoneyMusdEmptyBalanceRow', () => { + it('renders the MetaMask USD name', () => { + const { getByText } = renderWithProvider(); + expect(getByText('MetaMask USD')).toBeOnTheScreen(); + }); + + it('renders the zero fiat balance', () => { + const { getByTestId } = renderWithProvider(); + expect( + getByTestId(MoneyMusdEmptyBalanceRowTestIds.FIAT_BALANCE), + ).toHaveTextContent('$0.00'); + }); + + it('renders the zero native balance', () => { + const { getByTestId } = renderWithProvider(); + expect( + getByTestId(MoneyMusdEmptyBalanceRowTestIds.NATIVE_BALANCE), + ).toHaveTextContent('0 mUSD'); + }); + + it('calls onPress when the row is tapped', () => { + const onPress = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + fireEvent.press(getByTestId(MoneyMusdEmptyBalanceRowTestIds.CONTAINER)); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not throw when tapped without an onPress handler', () => { + const { getByTestId } = renderWithProvider(); + expect(() => + fireEvent.press(getByTestId(MoneyMusdEmptyBalanceRowTestIds.CONTAINER)), + ).not.toThrow(); + }); +}); diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.testIds.ts b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.testIds.ts new file mode 100644 index 00000000000..03882c7cab9 --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.testIds.ts @@ -0,0 +1,5 @@ +export const MoneyMusdEmptyBalanceRowTestIds = { + CONTAINER: 'money-musd-empty-balance-row-container', + FIAT_BALANCE: 'money-musd-empty-balance-row-fiat', + NATIVE_BALANCE: 'money-musd-empty-balance-row-native', +}; diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.tsx b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.tsx new file mode 100644 index 00000000000..6b5690c4049 --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { + AvatarToken, + AvatarTokenSize, + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { getNetworkImageSource } from '../../../../../util/networks'; +import { MUSD_TOKEN } from '../../../Earn/constants/musd'; +import { MoneyMusdEmptyBalanceRowTestIds } from './MoneyMusdEmptyBalanceRow.testIds'; +import type { ImageOrSvgSrc } from '@metamask/design-system-react-native/dist/components/temp-components/ImageOrSvg/ImageOrSvg.types.d.cts'; + +const styles = StyleSheet.create({ + badgeWrapper: { alignSelf: 'center' }, +}); + +interface MoneyMusdEmptyBalanceRowProps { + onPress?: () => void; +} + +const MoneyMusdEmptyBalanceRow = ({ + onPress, +}: MoneyMusdEmptyBalanceRowProps) => ( + + + + } + > + + + + + {MUSD_TOKEN.name} + + + + + $0.00 + + + {`0 ${MUSD_TOKEN.symbol}`} + + + + +); + +export default MoneyMusdEmptyBalanceRow; diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/index.ts b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/index.ts new file mode 100644 index 00000000000..eeab43f11e1 --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/index.ts @@ -0,0 +1 @@ +export { default } from './MoneyMusdEmptyBalanceRow'; diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts index 7927c1018b2..231477334a1 100644 --- a/app/components/UI/Money/constants/moneyEvents.ts +++ b/app/components/UI/Money/constants/moneyEvents.ts @@ -1,5 +1,6 @@ const EVENT_LOCATIONS = { MONEY_HUB: 'money_hub', + ASSET_DETAIL: 'asset_detail', }; const MONEY_HUB_STATES = { diff --git a/app/components/UI/Money/routes/index.test.tsx b/app/components/UI/Money/routes/index.test.tsx index ecccc38fa57..dd3ea38c257 100644 --- a/app/components/UI/Money/routes/index.test.tsx +++ b/app/components/UI/Money/routes/index.test.tsx @@ -96,4 +96,12 @@ describe('MoneyModalStack', () => { expect(getByTestId('money-screen-MoneyAddMoneySheet')).toBeOnTheScreen(); }); + + it('registers the Money balance info sheet as a modal screen', () => { + const { getByTestId } = renderWithProvider(, { + theme: themeWithCustomBackground, + }); + + expect(getByTestId('money-screen-MoneyBalanceInfoSheet')).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Money/routes/index.tsx b/app/components/UI/Money/routes/index.tsx index e2ebe150a2d..26334a35bf1 100644 --- a/app/components/UI/Money/routes/index.tsx +++ b/app/components/UI/Money/routes/index.tsx @@ -12,6 +12,7 @@ import MoneyMoreSheet from '../components/MoneyMoreSheet'; import MoneyTransferSheet from '../components/MoneyTransferSheet'; import MoneyApyInfoSheet from '../components/MoneyApyInfoSheet'; import MoneyEarningsInfoSheet from '../components/MoneyEarningsInfoSheet'; +import MoneyBalanceInfoSheet from '../components/MoneyBalanceInfoSheet'; import { Confirm } from '../../../Views/confirmations/components/confirm'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; @@ -24,6 +25,7 @@ const MoneyScreenStack = () => { return ( ( component={MoneyEarningsInfoSheet} options={{ headerShown: false }} /> + ); diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx index ab09e422929..1469dbcb56d 100644 --- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx +++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx @@ -27,6 +27,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { getNetworkImageSource } from '../../../util/networks'; import { parseCaipAssetType } from '@metamask/utils'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -102,10 +103,13 @@ const MultichainBridgeTransactionListItem = ({ const networkImageSource = getNetworkImageSource({ chainId }); return ( } > diff --git a/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx b/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx index 24b416ef220..ce4454228a7 100644 --- a/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx +++ b/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx @@ -21,6 +21,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { getNetworkImageSource } from '../../../util/networks'; import Routes from '../../../constants/navigation/Routes'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -91,10 +92,13 @@ const MultichainTransactionListItem = ({ return ( } > diff --git a/app/components/UI/Navbar/Navbar.testIds.ts b/app/components/UI/Navbar/Navbar.testIds.ts new file mode 100644 index 00000000000..503ce2f5862 --- /dev/null +++ b/app/components/UI/Navbar/Navbar.testIds.ts @@ -0,0 +1,4 @@ +export const NavbarSelectorsIDs = { + ANDROID_BACK_BUTTON: 'nav-android-back', + BACK_BUTTON_SIMPLE_WEBVIEW: 'back_button_simple_webview', +}; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index b639085f6dd..7c671c31fc8 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -5,7 +5,6 @@ import ModalNavbarTitle from '../ModalNavbarTitle'; import { Alert, Image, - Platform, StyleSheet, Text, TouchableOpacity, @@ -21,9 +20,7 @@ import { analytics } from '../../../util/analytics/analytics'; import { Authentication } from '../../../core'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import Device from '../../../util/device'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -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 { NavbarSelectorsIDs } from './Navbar.testIds'; import Routes from '../../../constants/navigation/Routes'; import { @@ -602,7 +599,7 @@ export function getClosableNavigationOptions( { type="confirm" onPress={onClose} containerStyle={styles.closeButton} - testID={NETWORK_EDUCATION_MODAL_CLOSE_BUTTON} + testID={NetworkEducationModalSelectorsIDs.CLOSE_BUTTON} > {strings('network_information.got_it')} diff --git a/app/components/UI/PhishingModal/PhishingModal.testIds.ts b/app/components/UI/PhishingModal/PhishingModal.testIds.ts new file mode 100644 index 00000000000..3346e5bb952 --- /dev/null +++ b/app/components/UI/PhishingModal/PhishingModal.testIds.ts @@ -0,0 +1,3 @@ +export const PhishingModalSelectorsIDs = { + DETECTION_TITLE: 'ethereum-detection-title', +}; diff --git a/app/components/UI/PhishingModal/index.js b/app/components/UI/PhishingModal/index.js index 9cf64191756..0e44773a893 100644 --- a/app/components/UI/PhishingModal/index.js +++ b/app/components/UI/PhishingModal/index.js @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - Platform, Linking, TouchableOpacity, } from 'react-native'; @@ -13,8 +12,7 @@ import { fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import URL from 'url-parse'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { ETHEREUM_DETECTION_TITLE } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/ExternalWebsites.testIds'; +import { PhishingModalSelectorsIDs } from './PhishingModal.testIds'; import Button from '../../../component-library/components/Buttons/Button/Button'; import { ButtonVariants, @@ -146,7 +144,7 @@ export default class PhishingModal extends PureComponent { {strings('phishing.site_might_be_harmful')} diff --git a/app/components/UI/Predict/constants/sports.ts b/app/components/UI/Predict/constants/sports.ts index bec3dada92b..3317116821f 100644 --- a/app/components/UI/Predict/constants/sports.ts +++ b/app/components/UI/Predict/constants/sports.ts @@ -50,6 +50,7 @@ export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = [ 'itc', 'dfb', 'cde', + 'fifwc', ]; export const filterSupportedLeagues = ( @@ -98,6 +99,7 @@ const DRAW_CAPABLE_LEAGUES: ReadonlySet = new Set([ 'itc', 'dfb', 'cde', + 'fifwc', ]); export const isDrawCapableLeague = (league: PredictSportsLeague): boolean => diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 2e221b190c4..6b0122eb258 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -5721,7 +5721,6 @@ describe('PredictController', () => { const mockAccountState = { address: '0xProxyAddress' as `0x${string}`, isDeployed: true, - hasAllowances: true, balance: 100.5, }; diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index b447b43a3f9..4396fb809e1 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -57,7 +57,7 @@ import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { - MATIC_CONTRACTS, + MATIC_CONTRACTS_V2, POLYMARKET_PROVIDER_ID, } from '../providers/polymarket/constants'; import { Signer } from '../providers/types'; @@ -1453,7 +1453,7 @@ export class PredictController extends BaseController< disableHook: true, disableSequential: true, // Temporarily breaking abstraction, can instead be abstracted via provider. - gasFeeToken: MATIC_CONTRACTS.collateral as Hex, + gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex, transactions, }); @@ -2564,7 +2564,7 @@ export class PredictController extends BaseController< disableSequential: true, requireApproval: true, // Temporarily breaking abstraction, can instead be abstracted via provider. - gasFeeToken: MATIC_CONTRACTS.collateral as Hex, + gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex, transactions: [transaction], }); diff --git a/app/components/UI/Predict/hooks/usePredictAccountState.test.ts b/app/components/UI/Predict/hooks/usePredictAccountState.test.ts index bfb564a4f72..29bf6383312 100644 --- a/app/components/UI/Predict/hooks/usePredictAccountState.test.ts +++ b/app/components/UI/Predict/hooks/usePredictAccountState.test.ts @@ -63,7 +63,6 @@ describe('usePredictAccountState', () => { const mockAccountState = { address: '0x1234567890abcdef1234567890abcdef12345678', isDeployed: true, - hasAllowances: true, }; beforeEach(() => { @@ -117,7 +116,6 @@ describe('usePredictAccountState', () => { expect(mockGetAccountState).toHaveBeenCalledWith({}); expect(result.current.data?.address).toEqual(mockAccountState.address); expect(result.current.data?.isDeployed).toBe(true); - expect(result.current.data?.hasAllowances).toBe(true); expect(result.current.error).toBeNull(); }); @@ -215,24 +213,6 @@ describe('usePredictAccountState', () => { expect(result.current.data?.isDeployed).toBe(false); }); - it('returns hasAllowances as false when account lacks allowances', async () => { - const { Wrapper } = createWrapper(); - mockGetAccountState.mockResolvedValue({ - ...mockAccountState, - hasAllowances: false, - }); - - const { result } = renderHook(() => usePredictAccountState(), { - wrapper: Wrapper, - }); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - expect(result.current.data?.hasAllowances).toBe(false); - }); - it('has undefined data when query is disabled', () => { const { Wrapper } = createWrapper(); const { result } = renderHook( diff --git a/app/components/UI/Predict/hooks/usePredictAccountState.ts b/app/components/UI/Predict/hooks/usePredictAccountState.ts index 02cfa57c024..1a75f0b9992 100644 --- a/app/components/UI/Predict/hooks/usePredictAccountState.ts +++ b/app/components/UI/Predict/hooks/usePredictAccountState.ts @@ -15,7 +15,7 @@ interface UsePredictAccountStateOptions { } /** - * Fetches the Predict account state (address, deployment status, allowances). + * Fetches the Predict account state (address and deployment status). */ export function usePredictAccountState( options: UsePredictAccountStateOptions = {}, diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts index 7328b19b460..2b1ba17cb24 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts @@ -52,10 +52,6 @@ jest.mock('../../../Views/confirmations/utils/transaction', () => ({ hasTransactionType: jest.fn(), })); -jest.mock('../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(() => 'polygon-network-badge'), -})); - const mockHasTransactionType = hasTransactionType as jest.MockedFunction< typeof hasTransactionType >; @@ -85,7 +81,7 @@ describe('usePredictBalanceTokenFilter', () => { mockPredictBalance = 100; mockTransactionMeta = null; mockHasTransactionType.mockReturnValue(false); - mockUseSelector.mockReturnValue({ image: 'usdce-token-image' }); + mockUseSelector.mockReturnValue({ image: 'pusd-token-image' }); mockNavigate.mockReset(); }); @@ -165,7 +161,7 @@ describe('usePredictBalanceTokenFilter', () => { expect((filteredTokens[0] as HighlightedItem).fiat).toBe('$42.50'); }); - it('shows name_description as USDC.e on the Predict balance row', () => { + it('shows name_description as pUSD on the Predict balance row', () => { mockHasTransactionType.mockReturnValue(true); const tokens = [createMockToken()]; @@ -173,11 +169,11 @@ describe('usePredictBalanceTokenFilter', () => { const filteredTokens = result.current(tokens); expect((filteredTokens[0] as HighlightedItem).name_description).toBe( - 'USDC.e', + 'pUSD', ); }); - it('uses empty string for icon when usdceToken is null', () => { + it('uses empty string for icon when pusdToken is null', () => { mockHasTransactionType.mockReturnValue(true); mockUseSelector.mockReturnValue(null); const tokens = [createMockToken()]; diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts index 4cd3d82b0f9..5ea39972534 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts @@ -1,12 +1,14 @@ +import { TransactionType } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; import { useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; import { RootState } from '../../../../reducers'; import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; -import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict'; -import { TransactionType } from '@metamask/transaction-controller'; +import { POLYGON_PUSD } from '../../../Views/confirmations/constants/predict'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType, @@ -17,8 +19,6 @@ import { hasTransactionType } from '../../../Views/confirmations/utils/transacti import { PREDICT_BALANCE_CHAIN_ID } from '../constants/transactions'; import { usePredictBalance } from './usePredictBalance'; import { usePredictPaymentToken } from './usePredictPaymentToken'; -import { strings } from '../../../../../locales/i18n'; -import Routes from '../../../../constants/navigation/Routes'; export function usePredictBalanceTokenFilter( forceEnabled = false, @@ -29,10 +29,10 @@ export function usePredictBalanceTokenFilter( const { isPredictBalanceSelected } = usePredictPaymentToken(); const { data: predictBalance = 0 } = usePredictBalance(); const formatFiat = useFiatFormatter({ currency: 'usd' }); - const usdceToken = useSelector((state: RootState) => + const pusdToken = useSelector((state: RootState) => selectSingleTokenByAddressAndChainId( state, - POLYGON_USDCE.address, + POLYGON_PUSD.address, PREDICT_BALANCE_CHAIN_ID, ), ); @@ -60,9 +60,9 @@ export function usePredictBalanceTokenFilter( const predictBalanceHighlightedItem: HighlightedItem = { position: 'in_asset_list', - icon: usdceToken?.image ?? '', + icon: pusdToken?.image ?? '', name: strings('predict.payment.predict_balance'), - name_description: POLYGON_USDCE.symbol, + name_description: POLYGON_PUSD.symbol, fiat: balanceFormatted, isSelected: isPredictBalanceSelected, action: onSelect ?? (() => undefined), @@ -90,7 +90,7 @@ export function usePredictBalanceTokenFilter( isPredictBalanceSelected, predictBalance, formatFiat, - usdceToken, + pusdToken, handleAddFunds, onSelect, ], diff --git a/app/components/UI/Predict/hooks/usePredictRewards.test.ts b/app/components/UI/Predict/hooks/usePredictRewards.test.ts index 662543c810b..08cda0c2956 100644 --- a/app/components/UI/Predict/hooks/usePredictRewards.test.ts +++ b/app/components/UI/Predict/hooks/usePredictRewards.test.ts @@ -7,7 +7,7 @@ import Logger from '../../../../util/Logger'; import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils'; import { POLYGON_MAINNET_CAIP_CHAIN_ID, - POLYGON_USDC_CAIP_ASSET_ID, + POLYGON_PUSD_CAIP_ASSET_ID, } from '../providers/polymarket/constants'; jest.mock('react-redux', () => ({ @@ -47,8 +47,8 @@ jest.mock('../constants/errors', () => ({ jest.mock('../providers/polymarket/constants', () => ({ POLYGON_MAINNET_CAIP_CHAIN_ID: 'eip155:137', - POLYGON_USDC_CAIP_ASSET_ID: - 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + POLYGON_PUSD_CAIP_ASSET_ID: + 'eip155:137/erc20:0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', COLLATERAL_TOKEN_DECIMALS: 6, })); @@ -185,7 +185,7 @@ describe('usePredictRewards', () => { activityContext: { predictContext: { feeAsset: { - id: POLYGON_USDC_CAIP_ASSET_ID, + id: POLYGON_PUSD_CAIP_ASSET_ID, amount: expect.any(String), }, }, diff --git a/app/components/UI/Predict/hooks/usePredictRewards.ts b/app/components/UI/Predict/hooks/usePredictRewards.ts index e1d2d908957..ae464624e59 100644 --- a/app/components/UI/Predict/hooks/usePredictRewards.ts +++ b/app/components/UI/Predict/hooks/usePredictRewards.ts @@ -17,7 +17,7 @@ import { selectSelectedInternalAccountByScope } from '../../../../selectors/mult import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils'; import { POLYGON_MAINNET_CAIP_CHAIN_ID, - POLYGON_USDC_CAIP_ASSET_ID, + POLYGON_PUSD_CAIP_ASSET_ID, COLLATERAL_TOKEN_DECIMALS, } from '../providers/polymarket/constants'; import { parseUnits } from 'ethers/lib/utils'; @@ -186,9 +186,9 @@ export const usePredictRewards = ( } // Prepare fee asset - // Convert USD amount to atomic units (6 decimals for USDC) + // Convert USD amount to atomic units (6 decimals for pUSD) const feeAsset: EstimateAssetDto = { - id: POLYGON_USDC_CAIP_ASSET_ID, + id: POLYGON_PUSD_CAIP_ASSET_ID, amount: parseUnits( totalFeeAmountUsd.toString(), COLLATERAL_TOKEN_DECIMALS, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 69b7b49e060..2e6bf89212e 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -1,145 +1,91 @@ +import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; +import type { OrderPreview } from '../types'; +import { Side } from '../../types'; +import type { PredictFeatureFlags } from '../../types/flags'; +import { PolymarketProvider } from './PolymarketProvider'; import { DEFAULT_CLOB_BASE_URL, - LEGACY_V2_CLOB_BASE_URL, + MATIC_CONTRACTS_V2, POLYMARKET_PROVIDER_ID, USDC_E_ADDRESS, } from './constants'; -// Mock external dependencies -jest.mock('../../../../../core/Engine', () => ({ - context: { - NetworkController: { - findNetworkClientIdByChainId: jest.fn(), - getNetworkClientById: jest.fn(), - }, - KeyringController: { - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }, - }, -})); - -jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => { - const mockLogger = { - log: jest.fn(), - }; - return { - __esModule: true, - DevLogger: mockLogger, - default: mockLogger, - }; -}); - -import { query } from '@metamask/controller-utils'; -import Engine from '../../../../../core/Engine'; -import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; -import { - generateTransferData, - isSmartContractAddress, -} from '../../../../../util/transactions'; -import { - PredictPosition, - PredictPositionStatus, - PredictPriceHistoryInterval, - Recurrence, - Side, -} from '../../types'; -import { PREDICT_ERROR_CODES } from '../../constants/errors'; -import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; -import type { PredictFeatureFlags } from '../../types/flags'; -import { submitProtocolClobOrder } from './protocol/transport'; -import { - extractNeededTeamsFromEvents, - getEventLeague, - isLiveSportsEvent, -} from '../../utils/gameParser'; -import { OrderPreview, PlaceOrderParams } from '../types'; -import { PolymarketProvider } from './PolymarketProvider'; import { computeProxyAddress, createPermit2FeeAuthorization, - createSafeFeeAuthorization, - getClaimTransaction, getDeployProxyWalletTransaction, - getProxyWalletAllowancesTransaction, - getWithdrawTransactionCallData, - hasAllowances, + getSafeTransferAmount, + getSafeTransferAmountRaw, } from './safe/utils'; -import { PERMIT2_ADDRESS } from './safe/constants'; import { createApiKey, - encodeClaim, - fetchChildEventsFromGammaApi, + encodeErc20Transfer, getBalance, - getRawBalance, - getContractConfig, - getFeeRateBps, - fetchEventsFromPolymarketApi, - fetchCarouselFromPolymarketApi, getL2Headers, - getMarketDetailsFromGammaApi, - getOrderTypedData, - getPolymarketEndpoints, - mergeChildEventsIntoParent, - parsePolymarketEvents, + getRawBalance, parsePolymarketPositions, previewOrder, - priceValid, - submitClobOrder, } from './utils'; +import { submitProtocolClobOrder } from './protocol/transport'; +import { buildDepositMaintenanceTransaction } from './preflight/deposit'; +import { buildTradeAllowancesTx } from './preflight/trade'; +import { buildWithdrawTransaction } from './preflight/withdraw'; +import { + generateTransferData, + isSmartContractAddress, +} from '../../../../../util/transactions'; -jest.mock('@metamask/controller-utils', () => { - const actual = jest.requireActual('@metamask/controller-utils'); - return { - ...actual, - query: jest.fn(), - }; -}); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { log: jest.fn() }, +})); + +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn(), log: jest.fn() }, +})); + +jest.mock('../../../../../util/analytics/analytics', () => ({ + analytics: { identify: jest.fn() }, +})); + +jest.mock('../../../../../util/transactions', () => ({ + generateTransferData: jest.fn(), + isSmartContractAddress: jest.fn(), +})); + +jest.mock('./safe/utils', () => ({ + computeProxyAddress: jest.fn(), + createPermit2FeeAuthorization: jest.fn(), + getDeployProxyWalletTransaction: jest.fn(), + getSafeTransferAmount: jest.fn(), + getSafeTransferAmountRaw: jest.fn(), +})); jest.mock('./utils', () => { const actual = jest.requireActual('./utils'); + return { ...actual, + createApiKey: jest.fn(), + encodeErc20Transfer: jest.fn(), + fetchCarouselFromPolymarketApi: jest.fn(), + fetchEventsFromPolymarketApi: jest.fn(), + getBalance: jest.fn(), + getL2Headers: jest.fn(), + getRawBalance: jest.fn(), + getMarketDetailsFromGammaApi: jest.fn(), getPolymarketEndpoints: jest.fn(() => ({ DATA_API_ENDPOINT: 'https://data-api.polymarket.com', GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', CLOB_ENDPOINT: 'https://clob.polymarket.com', + CLOB_RELAYER: 'https://predict.api.cx.metamask.io', GEOBLOCK_API_ENDPOINT: 'https://polymarket.com/api/geoblock', - CRYPTO_PRICE_ENDPOINT: 'https://polymarket.com/api/crypto/crypto-price', })), - getParsedMarketsFromPolymarketApi: jest.fn(), - fetchCarouselFromPolymarketApi: jest.fn(), - fetchEventsFromPolymarketApi: jest.fn().mockResolvedValue({ - events: [], - category: 'trending', - isSearch: false, - }), - getMarketsFromPolymarketApi: jest.fn(), - getMarketDetailsFromGammaApi: jest.fn(), - getTickSize: jest.fn(), - calculateMarketPrice: jest.fn(), - buildMarketOrderCreationArgs: jest.fn(), - encodeApprove: jest.fn(), - encodeClaim: jest.fn(), - encodeErc1155Approve: jest.fn(), - getAllowance: jest.fn().mockResolvedValue(1n), - getIsApprovedForAll: jest.fn().mockResolvedValue(true), - getContractConfig: jest.fn(), - getL2Headers: jest.fn(), - getFeeRateBps: jest.fn(), - getOrderBook: jest.fn(), - getOrderTypedData: jest.fn(), + parsePolymarketActivity: jest.fn(), parsePolymarketEvents: jest.fn(), parsePolymarketPositions: jest.fn(), - priceValid: jest.fn(), - createApiKey: jest.fn(), - submitClobOrder: jest.fn(), - getMarketPositions: jest.fn(), - fetchChildEventsFromGammaApi: jest.fn(), - mergeChildEventsIntoParent: jest.fn(), - getBalance: jest.fn(), - getRawBalance: jest.fn(), previewOrder: jest.fn(), - POLYGON_MAINNET_CHAIN_ID: 137, }; }); @@ -147,151 +93,85 @@ jest.mock('./protocol/transport', () => ({ submitProtocolClobOrder: jest.fn(), })); -jest.mock('./safe/utils', () => ({ - computeProxyAddress: jest.fn(), - createPermit2FeeAuthorization: jest.fn(), - createSafeFeeAuthorization: jest.fn(), - getClaimTransaction: jest.fn(), - getDeployProxyWalletTransaction: jest.fn(), - getProxyWalletAllowancesTransaction: jest.fn(), - hasAllowances: jest.fn(), - aggregateTransaction: jest.fn((txs) => txs[0]), - getSafeTransactionCallData: jest.fn().mockResolvedValue('0xsignedsafeexec'), - getWithdrawTransactionCallData: jest - .fn() - .mockResolvedValue('0xsignedcalldata'), - getSafeUsdcAmount: jest.fn().mockReturnValue(1), - getSafeUsdcAmountRaw: jest.fn().mockReturnValue(1000000n), +jest.mock('./preflight/deposit', () => ({ + buildDepositMaintenanceTransaction: jest.fn(), })); -const mockGameCacheInstance = { - overlayOnMarket: jest.fn((market) => market), - overlayOnMarkets: jest.fn((markets) => markets), - updateGame: jest.fn(), - getGame: jest.fn(), - pruneStaleEntries: jest.fn(), - cleanup: jest.fn(), - clear: jest.fn(), - getCacheSize: jest.fn(), - getCachedGameIds: jest.fn(), -}; - -jest.mock('./GameCache', () => ({ - GameCache: { - getInstance: jest.fn(() => mockGameCacheInstance), - resetInstance: jest.fn(), - }, +jest.mock('./preflight/trade', () => ({ + buildTradeAllowancesTx: jest.fn(), })); -jest.mock('../../constants/sports', () => ({ - SUPPORTED_SPORTS_LEAGUES: ['nfl'], - filterSupportedLeagues: (leagues: string[]) => - leagues.filter((l) => ['nfl'].includes(l)), +jest.mock('./preflight/withdraw', () => ({ + buildWithdrawTransaction: jest.fn(), })); -const mockTeamsCacheInstance = { - ensureLeagueLoaded: jest.fn().mockResolvedValue(undefined), - ensureLeaguesLoaded: jest.fn().mockResolvedValue(undefined), - ensureTeamsLoaded: jest.fn().mockResolvedValue(undefined), - getTeam: jest.fn(), - getNflTeam: jest.fn(), - isLeagueLoaded: jest.fn().mockReturnValue(true), - clear: jest.fn(), - getTeamCount: jest.fn().mockReturnValue(0), -}; +const mockComputeProxyAddress = jest.mocked(computeProxyAddress); +const mockCreateApiKey = jest.mocked(createApiKey); +const mockCreatePermit2FeeAuthorization = jest.mocked( + createPermit2FeeAuthorization, +); +const mockEncodeErc20Transfer = jest.mocked(encodeErc20Transfer); +const mockGenerateTransferData = jest.mocked(generateTransferData); +const mockGetBalance = jest.mocked(getBalance); +const mockGetDeployProxyWalletTransaction = jest.mocked( + getDeployProxyWalletTransaction, +); +const mockGetL2Headers = jest.mocked(getL2Headers); +const mockGetRawBalance = jest.mocked(getRawBalance); +const mockGetSafeTransferAmount = jest.mocked(getSafeTransferAmount); +const mockGetSafeTransferAmountRaw = jest.mocked(getSafeTransferAmountRaw); +const mockIsSmartContractAddress = jest.mocked(isSmartContractAddress); +const mockParsePolymarketPositions = jest.mocked(parsePolymarketPositions); +const mockPreviewOrder = jest.mocked(previewOrder); +const mockSubmitProtocolClobOrder = jest.mocked(submitProtocolClobOrder); +const mockBuildDepositMaintenanceTransaction = jest.mocked( + buildDepositMaintenanceTransaction, +); +const mockBuildTradeAllowancesTx = jest.mocked(buildTradeAllowancesTx); +const mockBuildWithdrawTransaction = jest.mocked(buildWithdrawTransaction); -jest.mock('./TeamsCache', () => ({ - TeamsCache: { - getInstance: jest.fn(() => mockTeamsCacheInstance), - resetInstance: jest.fn(), - }, -})); +const signer = { + address: '0x1111111111111111111111111111111111111111', + signPersonalMessage: jest.fn(), + signTypedMessage: jest.fn(), +}; -const mockWebSocketManagerInstance = { - subscribeToGame: jest.fn(), - subscribeToMarketPrices: jest.fn(), - subscribeToCryptoPrices: jest.fn(), - getConnectionStatus: jest.fn(), - disconnect: jest.fn(), - cleanup: jest.fn(), +const basePreview: OrderPreview = { + marketId: 'market-1', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcomeTokenId: '123', + timestamp: 1, + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 10, + minAmountReceived: 19, + slippage: 0.03, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + feeRateBps: '99', }; -jest.mock('./WebSocketManager', () => ({ - WebSocketManager: { - getInstance: jest.fn(() => mockWebSocketManagerInstance), - resetInstance: jest.fn(), +const defaultFeatureFlags: PredictFeatureFlags = { + feeCollection: DEFAULT_FEE_COLLECTION_FLAG, + liveSportsLeagues: [], + extendedSportsMarketsLeagues: [], + marketHighlightsFlag: { + enabled: false, + highlights: [], + minimumVersion: '7.64.0', }, -})); - -jest.mock('../../utils/gameParser', () => ({ - ...jest.requireActual('../../utils/gameParser'), - extractNeededTeamsFromEvents: jest.fn(() => new Map()), - getEventLeague: jest.fn(() => null), - isLiveSportsEvent: jest.fn(), - parseGameSlugTeams: jest.fn(() => null), -})); - -jest.mock('../../../../../util/transactions', () => ({ - generateTransferData: jest.fn(), - isSmartContractAddress: jest.fn(), -})); - -const mockFindNetworkClientIdByChainId = Engine.context.NetworkController - .findNetworkClientIdByChainId as jest.Mock; -const mockGetNetworkClientById = Engine.context.NetworkController - .getNetworkClientById as jest.Mock; -const mockSignTypedMessage = Engine.context.KeyringController - .signTypedMessage as jest.Mock; -const mockSignPersonalMessage = Engine.context.KeyringController - .signPersonalMessage as jest.Mock; -const mockFetchEventsFromPolymarketApi = - fetchEventsFromPolymarketApi as jest.Mock; -const mockFetchCarouselFromPolymarketApi = - fetchCarouselFromPolymarketApi as jest.Mock; -const mockGetMarketDetailsFromGammaApi = - getMarketDetailsFromGammaApi as jest.Mock; -const mockGetContractConfig = getContractConfig as jest.Mock; -const mockGetFeeRateBps = getFeeRateBps as jest.Mock; -const mockGetL2Headers = getL2Headers as jest.Mock; -const mockGetOrderTypedData = getOrderTypedData as jest.Mock; -const mockParsePolymarketEvents = parsePolymarketEvents as jest.Mock; -const mockParsePolymarketPositions = parsePolymarketPositions as jest.Mock; -const mockPriceValid = priceValid as jest.Mock; -const mockCreateApiKey = createApiKey as jest.Mock; -const mockSubmitClobOrder = submitClobOrder as jest.Mock; -const mockEncodeClaim = encodeClaim as jest.Mock; -const mockComputeProxyAddress = computeProxyAddress as jest.Mock; -const mockCreatePermit2FeeAuthorization = - createPermit2FeeAuthorization as jest.Mock; -const mockCreateSafeFeeAuthorization = createSafeFeeAuthorization as jest.Mock; -const mockGetClaimTransaction = getClaimTransaction as jest.Mock; -const mockHasAllowances = hasAllowances as jest.Mock; -const mockQuery = query as jest.Mock; -const mockPreviewOrder = previewOrder as jest.Mock; -const mockGetBalance = getBalance as jest.Mock; -const mockGetRawBalance = getRawBalance as jest.Mock; -const mockSubmitProtocolClobOrder = submitProtocolClobOrder as jest.Mock; -const mockIsLiveSportsEvent = isLiveSportsEvent as jest.Mock; -const mockGetEventLeague = getEventLeague as jest.Mock; -const mockFetchChildEventsFromGammaApi = - fetchChildEventsFromGammaApi as jest.Mock; -const mockMergeChildEventsIntoParent = mergeChildEventsIntoParent as jest.Mock; -const mockExtractNeededTeamsFromEvents = - extractNeededTeamsFromEvents as jest.Mock; -const { getEventLeague: actualGetEventLeague } = jest.requireActual( - '../../utils/gameParser', -); + fakOrdersEnabled: false, + predictWithAnyTokenEnabled: false, + predictUpDownEnabled: false, +}; -mockIsLiveSportsEvent.mockImplementation( - ( - event: Parameters[0], - enabledLeagues: string[], - extendedSportsMarketsLeagues: string[] = [], - ) => { - const league = mockGetEventLeague(event, extendedSportsMarketsLeagues); - return league !== null && enabledLeagues.includes(league); - }, -); +function createProvider(featureFlags?: Partial) { + return new PolymarketProvider({ + getFeatureFlags: () => ({ ...defaultFeatureFlags, ...featureFlags }), + }); +} describe('PolymarketProvider', () => { const originalBuilderCode = process.env.MM_PREDICT_BUILDER_CODE; @@ -310,8754 +190,252 @@ describe('PolymarketProvider', () => { process.env.MM_PREDICT_BUILDER_CODE = originalBuilderCode; }); - const defaultFeatureFlags: PredictFeatureFlags = { - feeCollection: DEFAULT_FEE_COLLECTION_FLAG, - liveSportsLeagues: [], - extendedSportsMarketsLeagues: [], - marketHighlightsFlag: { - enabled: false, - highlights: [], - minimumVersion: '7.64.0', - }, - fakOrdersEnabled: false, - predictWithAnyTokenEnabled: false, - predictUpDownEnabled: false, - predictClobV2Enabled: false, - }; - const createProvider = ( - featureFlagsOverride?: Partial, - ) => - new PolymarketProvider({ - getFeatureFlags: () => ({ - ...defaultFeatureFlags, - ...featureFlagsOverride, - }), + beforeEach(() => { + jest.clearAllMocks(); + mockComputeProxyAddress.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + mockCreateApiKey.mockResolvedValue({ + apiKey: 'api-key', + secret: 'secret', + passphrase: 'passphrase', + }); + mockGetL2Headers.mockResolvedValue({ + POLY_ADDRESS: signer.address, + POLY_SIGNATURE: 'sig', + POLY_TIMESTAMP: '1', + POLY_API_KEY: 'api-key', + POLY_PASSPHRASE: 'passphrase', }); - - it('exposes the correct providerId', () => { - const provider = createProvider(); - expect(provider.providerId).toBe(POLYMARKET_PROVIDER_ID); - }); - - it('getMarkets returns an array with some length', async () => { - const mockEvents = [ - { - id: 'market-1', - title: 'Test Market 1', - description: 'A test market', - icon: 'https://example.com/icon1.png', - closed: false, - series: 'Test Series', - tags: [{ slug: 'trending' }, { slug: 'crypto' }], - markets: [ - { - conditionId: 'cond-1', - question: 'Will Bitcoin reach $100k?', - description: 'Bitcoin price prediction', - icon: 'https://example.com/market1.png', - image: 'https://example.com/market1.png', - groupItemTitle: 'Bitcoin', - closed: false, - volume: '1000000', - clobTokenIds: '["0","1"]', - outcomes: '["Yes","No"]', - outcomePrices: '["0.6","0.4"]', - }, - ], - }, - { - id: 'market-2', - title: 'Test Market 2', - description: 'Another test market', - icon: 'https://example.com/icon2.png', - closed: false, - series: 'Test Series 2', - tags: [{ slug: 'sports' }], - markets: [ - { - conditionId: 'cond-2', - question: 'Will the Lakers win?', - description: 'NBA prediction', - icon: 'https://example.com/market2.png', - image: 'https://example.com/market2.png', - groupItemTitle: 'Lakers', - closed: false, - volume: '500000', - clobTokenIds: '["0","1"]', - outcomes: '["Yes","No"]', - outcomePrices: '["0.7","0.3"]', - }, - ], - }, - ]; - - const parsedMarkets = [ - { - id: 'market-1', - title: 'Test Market 1', + mockParsePolymarketPositions.mockResolvedValue([]); + mockSubmitProtocolClobOrder.mockResolvedValue({ + success: true, + response: { + success: true, + orderID: 'order-1', + makingAmount: '10', + takingAmount: '19', }, - { - id: 'market-2', - title: 'Test Market 2', + }); + mockPreviewOrder.mockResolvedValue(basePreview); + mockBuildTradeAllowancesTx.mockResolvedValue({ + to: '0x9999999999999999999999999999999999999999', + data: '0xallowances', + }); + mockGenerateTransferData.mockReturnValue('0xtransferData'); + mockIsSmartContractAddress.mockResolvedValue(true); + mockGetDeployProxyWalletTransaction.mockResolvedValue({ + params: { to: '0xFactory', data: '0xdeploy' }, + type: TransactionType.contractInteraction, + }); + mockBuildDepositMaintenanceTransaction.mockResolvedValue(undefined); + mockEncodeErc20Transfer.mockReturnValue('0xtransfer'); + mockGetRawBalance.mockResolvedValue(0n); + mockGetSafeTransferAmount.mockReturnValue(1); + mockGetSafeTransferAmountRaw.mockReturnValue(1_000_000n); + mockBuildWithdrawTransaction.mockResolvedValue({ + params: { + to: '0x9999999999999999999999999999999999999999', + data: '0xsignedWithdraw', }, - ]; - - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, + type: TransactionType.predictWithdraw, }); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue(parsedMarkets); - - const markets = await createProvider({ - liveSportsLeagues: ['nfl'], - }).getMarkets(); - expect(Array.isArray(markets)).toBe(true); - expect(markets.length).toBeGreaterThan(0); - expect(markets.length).toBe(2); - expect(mockFetchEventsFromPolymarketApi).toHaveBeenCalled(); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ - category: 'trending', - sortMarketsBy: 'price', - teamLookup: expect.any(Function), - }), - ); - }); - - it('getMarkets returns empty array when API fails', async () => { - const apiError = new Error('API request failed'); - mockFetchEventsFromPolymarketApi.mockRejectedValue(apiError); - - const result = await createProvider({ - liveSportsLeagues: ['nfl'], - }).getMarkets(); - - expect(result).toEqual([]); - expect(mockFetchEventsFromPolymarketApi).toHaveBeenCalled(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + signer.signTypedMessage.mockResolvedValue('0xsigned-order'); }); - it('getMarkets returns empty array when non-Error exception is thrown', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockRejectedValue('String error'); - - const result = await provider.getMarkets(); - - expect(result).toEqual([]); + it('exposes the Polymarket provider id', () => { + expect(createProvider().providerId).toBe(POLYMARKET_PROVIDER_ID); }); - it('getPositions returns an empty array when API returns none', async () => { + it('previews orders through canonical CLOB v2 with zero fee-rate bps', async () => { const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - mockParsePolymarketPositions.mockResolvedValue([]); - const result = await provider.getPositions({ - address: '0x0000000000000000000000000000000000000000', - }); - - expect(result).toEqual([]); - expect(mockParsePolymarketPositions).toHaveBeenCalledWith({ - positions: [], + const preview = await provider.previewOrder({ + ...basePreview, + size: 10, + signer, }); - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; + expect(preview.feeRateBps).toBe('0'); + expect(mockPreviewOrder).toHaveBeenCalledWith( + expect.objectContaining({ feeCollection: DEFAULT_FEE_COLLECTION_FLAG }), + ); }); - it('getPositions maps providerId to polymarket on each returned position', async () => { + it('submits orders through the protocol CLOB v2 relayer path', async () => { const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - // Mock API response with PolymarketPosition format - const mockApiResponse = [ - { - providerId: 'external', - conditionId: 'c-1', - icon: 'https://example.com/icon.png', - title: 'Some Market', - slug: 'some-market', - size: 2, - outcome: 'Yes', - outcomeIndex: 0, - cashPnl: 1.23, - curPrice: 0.45, - currentValue: 0.9, - percentPnl: 10, - initialValue: 0.82, - avgPrice: 0.41, - redeemable: false, - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-1', - }, - ]; - - // Mock the parsed result - const mockParsedPositions = [ - { - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'c-1', - outcomeTokenId: 0, - title: 'Some Market', - icon: 'https://example.com/icon.png', - size: 2, - outcome: 'Yes', - cashPnl: 1.23, - curPrice: 0.45, - currentValue: 0.9, - percentPnl: 10, - initialValue: 0.82, - avgPrice: 0.41, - redeemable: false, - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-1', - }, - ]; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockApiResponse), - }); + const result = await provider.placeOrder({ signer, preview: basePreview }); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', + expect(result.success).toBe(true); + expect(mockCreateApiKey).toHaveBeenCalledWith({ address: signer.address }); + expect(signer.signTypedMessage).toHaveBeenCalledWith( + expect.any(Object), + SignTypedDataVersion.V4, + ); + expect(mockSubmitProtocolClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + protocol: expect.objectContaining({ + key: 'v2', + transport: expect.objectContaining({ + clobBaseUrl: DEFAULT_CLOB_BASE_URL, + clobVersionHeader: '2', + }), + }), + allowancesTx: { + to: '0x9999999999999999999999999999999999999999', + data: '0xallowances', + }, + }), ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - mockParsePolymarketPositions.mockResolvedValue(mockParsedPositions); - - const result = await provider.getPositions({ - address: '0x0000000000000000000000000000000000000000', - }); - - expect(result).toHaveLength(1); - expect(result[0].providerId).toBe(POLYMARKET_PROVIDER_ID); - expect(result[0].marketId).toBe('c-1'); - expect(result[0].outcomeTokenId).toBe(0); - expect(mockParsePolymarketPositions).toHaveBeenCalledWith({ - positions: mockApiResponse, - }); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; }); - it('getPositions uses default pagination and correct query params', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), + it('uses pUSD Permit2 fee authorization when fees are present', async () => { + mockCreatePermit2FeeAuthorization.mockResolvedValue({ + type: 'safe-permit2', + authorization: { + permit: { + permitted: { token: MATIC_CONTRACTS_V2.collateral, amount: '100000' }, + nonce: '1', + deadline: '2', + }, + spender: '0x2222222222222222222222222222222222222222', + signature: '0xsig', + }, }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, + const provider = createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0x2222222222222222222222222222222222222222'], + }, }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - const userAddress = '0x1111111111111111111111111111111111111111'; - const safeAddress = '0x9999999999999999999999999999999999999999'; - await provider.getPositions({ address: userAddress }); + await provider.placeOrder({ + signer, + preview: { + ...basePreview, + fees: { + metamaskFee: 0.05, + providerFee: 0.05, + totalFee: 0.1, + totalFeePercentage: 1, + collector: '0x3333333333333333333333333333333333333333', + executors: ['0x2222222222222222222222222222222222222222'], + permit2Enabled: true, + }, + }, + }); - expect(mockFetch).toHaveBeenCalledTimes(1); - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - const { DATA_API_ENDPOINT } = getPolymarketEndpoints(); - expect(calledWithUrl.startsWith(`${DATA_API_ENDPOINT}/positions?`)).toBe( - true, + expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + safeAddress: '0x9999999999999999999999999999999999999999', + tokenAddress: MATIC_CONTRACTS_V2.collateral, + }), ); - expect(calledWithUrl).toContain('limit=100'); - expect(calledWithUrl).toContain('offset=0'); - expect(calledWithUrl).toContain(`user=${safeAddress}`); - expect(calledWithUrl).toContain('sortBy=CURRENT'); - expect(calledWithUrl).not.toContain('redeemable'); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; }); - it('getPositions applies offset and uses provided limit in the request', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, + it('prepares pUSD deposits and optional legacy sweep maintenance', async () => { + mockIsSmartContractAddress.mockResolvedValue(false); + mockBuildDepositMaintenanceTransaction.mockResolvedValue({ + params: { + to: '0x9999999999999999999999999999999999999999', + data: '0xmaintenance', + }, + type: TransactionType.contractInteraction, }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - const userAddress = '0x2222222222222222222222222222222222222222'; - const safeAddress = '0x9999999999999999999999999999999999999999'; - await provider.getPositions({ address: userAddress, limit: 5, offset: 15 }); - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - expect(calledWithUrl).toContain('limit=5'); - expect(calledWithUrl).toContain('offset=15'); - expect(calledWithUrl).toContain(`user=${safeAddress}`); - expect(calledWithUrl).toContain('sortBy=CURRENT'); - expect(calledWithUrl).not.toContain('redeemable'); + const result = await createProvider().prepareDeposit({ signer }); - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; + expect(result.chainId).toBe(CHAIN_IDS.POLYGON); + expect(result.transactions).toEqual([ + expect.objectContaining({ + params: { to: '0xFactory', data: '0xdeploy' }, + }), + { + params: { + to: MATIC_CONTRACTS_V2.collateral, + data: '0xtransferData', + }, + type: TransactionType.predictDeposit, + }, + expect.objectContaining({ + params: { + to: '0x9999999999999999999999999999999999999999', + data: '0xmaintenance', + }, + }), + ]); }); - it('getPositions rejects when the network request fails', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockRejectedValue(new Error('network failure')); + it('reads displayed Predict balance from pUSD plus legacy USDC.e', async () => { + mockGetBalance.mockResolvedValue(12.5); + mockGetRawBalance.mockResolvedValue(2_500_000n); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, + const balance = await createProvider().getBalance({ + address: signer.address, }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - await expect( - provider.getPositions({ - address: '0x3333333333333333333333333333333333333333', - }), - ).rejects.toThrow('network failure'); - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; + expect(balance).toBe(15); + expect(mockGetBalance).toHaveBeenCalledTimes(1); + expect(mockGetBalance).toHaveBeenCalledWith({ + address: '0x9999999999999999999999999999999999999999', + tokenAddress: MATIC_CONTRACTS_V2.collateral, + }); + expect(mockGetRawBalance).toHaveBeenCalledWith({ + address: '0x9999999999999999999999999999999999999999', + tokenAddress: USDC_E_ADDRESS, + }); }); - it('throws error when address is missing in getPositions', async () => { + it('caches zero legacy USDC.e balances in memory', async () => { + mockGetBalance.mockResolvedValue(12.5); + mockGetRawBalance.mockResolvedValue(0n); const provider = createProvider(); - await expect(provider.getPositions({ address: '' })).rejects.toThrow( - 'Address is required', - ); + await provider.getBalance({ address: signer.address }); + await provider.getBalance({ address: signer.address }); + + expect(mockGetBalance).toHaveBeenCalledTimes(2); + expect(mockGetRawBalance).toHaveBeenCalledTimes(1); }); - it('throws error when API response is not ok in getPositions', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: false, - status: 500, - }); + it('prepares editable pUSD withdraw transfers', async () => { + const result = await createProvider().prepareWithdraw({ signer }); - mockComputeProxyAddress.mockReturnValue( + expect(result.predictAddress).toBe( '0x9999999999999999999999999999999999999999', ); - - await expect( - provider.getPositions({ - address: '0x1234567890123456789012345678901234567890', + expect(result.transaction).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + to: MATIC_CONTRACTS_V2.collateral, + data: '0xtransfer', + }), + type: TransactionType.predictWithdraw, }), - ).rejects.toThrow('Failed to get positions'); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - it('getPositions uses claimable parameter correctly', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - const userAddress = '0x4444444444444444444444444444444444444444'; - const safeAddress = '0x9999999999999999999999999999999999999999'; - await provider.getPositions({ address: userAddress, claimable: true }); - - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - expect(calledWithUrl).toContain('redeemable=true'); - expect(calledWithUrl).toContain(`user=${safeAddress}`); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; }); - it('getPositions includes marketId in query when provided', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), + it('signs pUSD Safe withdraw executions', async () => { + const result = await createProvider().signWithdraw?.({ + signer, + callData: '0xtransfer', }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', + expect(mockBuildWithdrawTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + signer, + safeAddress: '0x9999999999999999999999999999999999999999', + requestedAmountRaw: 1_000_000n, + protocol: expect.objectContaining({ key: 'v2' }), + }), ); - - const userAddress = '0x5555555555555555555555555555555555555555'; - await provider.getPositions({ - address: userAddress, - marketId: 'market-123', - }); - - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - expect(calledWithUrl).toContain('eventId=market-123'); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - it('getPositions filters out claimable positions when claimable parameter is false', async () => { - // Arrange - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - const mockApiResponse = [ - { - id: 'pos-1', - market: 'c-1', - outcome: 0, - size: 1, - price: 0.5, - outcomeIndex: 0, - cashPnl: 0, - curPrice: 0.5, - currentValue: 0.5, - percentPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - redeemable: true, // This should be filtered out - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-1', - conditionId: 'c-1', - icon: 'https://example.com/icon.png', - title: 'Some Market', - slug: 'some-market', - }, - { - id: 'pos-2', - market: 'c-2', - outcome: 0, - size: 2, - price: 0.6, - outcomeIndex: 0, - cashPnl: 0, - curPrice: 0.6, - currentValue: 1.2, - percentPnl: 0, - initialValue: 1.0, - avgPrice: 0.5, - redeemable: false, // This should be kept - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-2', - conditionId: 'c-2', - icon: 'https://example.com/icon2.png', - title: 'Another Market', - slug: 'another-market', - }, - ]; - - // Mock the parsed result with only non-claimable positions (API should filter when claimable=false) - const mockParsedPositions = [ - { - id: 'pos-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'c-2', - outcomeTokenId: 0, - title: 'Another Market', - icon: 'https://example.com/icon2.png', - size: 2, - outcome: 'Yes', - cashPnl: 0, - curPrice: 0.6, - currentValue: 1.2, - percentPnl: 0, - initialValue: 1.0, - avgPrice: 0.5, - claimable: false, // This should be kept - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-2', - outcomeIndex: 0, - outcomeId: 'c-2', - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - amount: 2, - price: 0.6, - }, - ]; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockApiResponse), - }); - - mockParsePolymarketPositions.mockResolvedValue(mockParsedPositions); - - // Act - const result = await provider.getPositions({ - address: '0x123', - claimable: false, // This should filter out claimable positions - }); - - // Assert - expect(result).toHaveLength(1); - expect(result[0].id).toBe('pos-2'); // Only the non-claimable position should remain - expect(result[0].claimable).toBe(false); - - // Restore fetch - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - // Helper function to create a mock PredictPosition - function createMockPosition( - overrides?: Partial, - ): PredictPosition { - return { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: 'token-1', - currentValue: 100, - title: 'Test Market', - icon: 'https://example.com/icon.png', - amount: 10, - price: 0.5, - status: PredictPositionStatus.OPEN, - size: 10, - outcomeIndex: 0, - percentPnl: 0, - cashPnl: 0, - claimable: false, - initialValue: 100, - avgPrice: 0.5, - endDate: '2025-12-31T23:59:59Z', - ...overrides, - }; - } - - // Helper function to create a mock OrderPreview - function createMockOrderPreview( - overrides?: Partial, - ): OrderPreview { - return { - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeTokenId: '0', - timestamp: Date.now(), - side: Side.BUY, - sharePrice: 0.5, - maxAmountSpent: 1, - minAmountReceived: 2, - slippage: 0.005, - tickSize: 0.01, - minOrderSize: 0.01, - negRisk: false, - feeRateBps: '0', - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - ...overrides, - }; - } - - // Helper function to setup place order test environment - function setupPlaceOrderTest( - featureFlagsOverride?: Partial, - ) { - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - - const provider = createProvider(featureFlagsOverride); - - const mockMarket = { - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - slug: 'test-market', - title: 'Test Market', - description: 'A test market for prediction', - image: 'test-image.png', - status: 'open' as const, - recurrence: Recurrence.NONE, - categories: [], - outcomes: [], - }; - - // Setup default mocks - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockSignPersonalMessage.mockResolvedValue('0xpersonalsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - mockCreatePermit2FeeAuthorization.mockResolvedValue({ - type: 'safe-permit2', - authorization: { - permit: { - permitted: { - token: '0xCollateralAddress', - amount: '40000', - }, - nonce: '0', - deadline: '1700000000', - }, - spender: '0x1111111111111111111111111111111111111111', - signature: '0xpermit2sig', - }, - }); - - mockPriceValid.mockReturnValue(true); - - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - orderID: 'order-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }); - mockSubmitProtocolClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - orderID: 'order-v2-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }); - mockGetRawBalance.mockResolvedValue(0n); - - mockGetFeeRateBps.mockResolvedValue('0'); - - return { - provider, - mockAddress, - mockSigner, - mockMarket, - }; - } - - // Helper function to create optimistic position for testing - function createOptimisticPosition( - overrides?: Partial, - ): PredictPosition { - return { - ...createMockPosition(overrides), - optimistic: true, - ...overrides, - }; - } - - // Helper function to setup optimistic update test environment - function setupOptimisticUpdateTest() { - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - const provider = createProvider(); - - // Setup common mocks - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ provider: {} }); - mockQuery.mockResolvedValue('0x1'); - - const mockFetch = jest.fn(); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - - return { - provider, - mockAddress, - mockSigner, - mockFetch, - }; - } - - // Helper function to mock getMarketDetails response - function mockMarketDetailsForOptimistic(params: { - marketId: string; - outcomes: { - id: string; - title: string; - tokenId: string; - price: number; - }[]; - }) { - mockGetMarketDetailsFromGammaApi.mockResolvedValue({ - id: params.marketId, - question: 'Test Market', - markets: [], - }); - - mockParsePolymarketEvents.mockReturnValue([ - { - id: params.marketId, - providerId: POLYMARKET_PROVIDER_ID, - slug: 'test-market', - title: 'Test Market', - description: 'A test market', - image: 'https://example.com/market.png', - status: 'open', - recurrence: Recurrence.NONE, - categories: [], - outcomes: params.outcomes.map((outcome) => ({ - id: outcome.id, - providerId: POLYMARKET_PROVIDER_ID, - marketId: params.marketId, - title: outcome.title, - description: outcome.title, - image: 'https://example.com/outcome.png', - status: 'open', - tokens: [ - { - id: outcome.tokenId, - title: outcome.title, - price: outcome.price, - }, - ], - volume: 1000, - groupItemTitle: 'Test Group', - })), - liquidity: 10000, - volume: 20000, - }, - ]); - } - - describe('placeOrder', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('successfully places a buy order and returns correct result', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result).toMatchObject({ - success: true, - response: expect.any(Object), - }); - expect(result).not.toHaveProperty('error'); - }); - - it('successfully places a sell order and returns correct result', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.SELL }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result).toMatchObject({ - success: true, - response: expect.any(Object), - }); - expect(result).not.toHaveProperty('error'); - }); - - it('handles order submission failure', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: 'Submission failed', - }); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Submission failed'); - }); - - it('catches exceptions and returns error result instead of throwing', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSignTypedMessage.mockRejectedValue(new Error('Signature rejected')); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Signature rejected'); - }); - - it('catches non-Error exceptions and returns error result', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSignTypedMessage.mockRejectedValue('String error'); - const preview = createMockOrderPreview({ side: Side.BUY }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to place order'); - }); - - it('logs error details when exception occurs', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const mockError = new Error('Network error'); - mockSignTypedMessage.mockRejectedValue(mockError); - const preview = createMockOrderPreview({ side: Side.SELL }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - await provider.placeOrder(orderParams); - - // Assert - expect(DevLogger.log).toHaveBeenCalledWith( - 'PolymarketProvider: Place order failed', - expect.objectContaining({ - error: 'Network error', - side: Side.SELL, - outcomeTokenId: preview.outcomeTokenId, - }), - ); - }); - - it('calls all required utility functions with correct parameters', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - await provider.placeOrder(orderParams); - - // Assert - expect(mockSignTypedMessage).toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalled(); - }); - - it('uses the protocol transport and zero preview fee rate when CLOB v2 is enabled', async () => { - const { provider, mockSigner } = setupPlaceOrderTest({ - predictClobV2Enabled: true, - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0x1111111111111111111111111111111111111111'], - }, - fakOrdersEnabled: true, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - feeRateBps: '123', - fees: { - totalFee: 1, - metamaskFee: 0.5, - providerFee: 0.5, - totalFeePercentage: 1, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - const submitArgs = mockSubmitProtocolClobOrder.mock.calls[0][0]; - - expect(result.success).toBe(true); - expect(submitArgs.protocol).toEqual( - expect.objectContaining({ key: 'v2' }), - ); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner.address, - clobVersion: 'v2', - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }); - expect(submitArgs.clobOrder).toEqual( - expect.objectContaining({ - orderType: 'FAK', - order: expect.objectContaining({ - metadata: expect.any(String), - builder: expect.any(String), - }), - }), - ); - expect(submitArgs.clobOrder.order).not.toHaveProperty('feeRateBps'); - expect(mockSubmitClobOrder).not.toHaveBeenCalled(); - }); - - it('reuses the protocol resolved in placeOrder for v1 submission', async () => { - const { mockSigner } = setupPlaceOrderTest(); - let featureFlagReadCount = 0; - const provider = new PolymarketProvider({ - getFeatureFlags: () => { - featureFlagReadCount += 1; - return { - ...defaultFeatureFlags, - predictClobV2Enabled: featureFlagReadCount > 1, - }; - }, - }); - jest.spyOn(provider, 'getPositions').mockResolvedValue([]); - const preview = createMockOrderPreview({ side: Side.BUY }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(true); - expect(mockSubmitClobOrder).toHaveBeenCalledTimes(1); - expect(mockSubmitProtocolClobOrder).not.toHaveBeenCalled(); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner.address, - clobVersion: 'v1', - }); - }); - - it('aborts v2 order placement when trade preflight fails', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - predictClobV2Enabled: true, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: undefined, - }); - - mockGetRawBalance.mockRejectedValueOnce(new Error('balance read failed')); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to prepare v2 trade preflight'); - expect(mockSubmitProtocolClobOrder).not.toHaveBeenCalled(); - }); - - it('returns error result when maker address is not found', async () => { - // Arrange - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - const preview = createMockOrderPreview({ side: Side.BUY }); - - mockComputeProxyAddress.mockReturnValue(''); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ provider: {} }); - mockQuery.mockResolvedValue('0x0'); - - // Act - const result = await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Maker address not found'); - }); - - it('returns BUY_ORDER_NOT_FULLY_FILLED error when buy order cannot be fully filled', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: `order couldn't be fully filled`, - }); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe(PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED); - }); - - it('returns SELL_ORDER_NOT_FULLY_FILLED error when sell order cannot be fully filled', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: `order couldn't be fully filled`, - }); - const preview = createMockOrderPreview({ side: Side.SELL }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe( - PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED, - ); - }); - - it('fetches account state when not cached during placeOrder', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockComputeProxyAddress).toHaveBeenCalled(); - }); - - it('uses negRiskExchange contract for negRisk orders', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - negRisk: true, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - verifyingContract: '0x0987654321098765432109876543210987654321', - }), - ); - }); - - it('uses exchange contract for non-negRisk orders', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - negRisk: false, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - verifyingContract: '0x1234567890123456789012345678901234567890', - }), - ); - }); - - it('uses preview feeRateBps when creating signed order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - feeRateBps: '30', - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - order: expect.objectContaining({ - feeRateBps: '30', - }), - }), - ); - }); - - it('uses zero feeRateBps when preview feeRateBps is missing', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - feeRateBps: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - order: expect.objectContaining({ - feeRateBps: '0', - }), - }), - ); - }); - }); - - describe('previewOrder', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockPreviewOrder.mockResolvedValue({}); - }); - - const createPreviewSigner = () => ({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }); - - const createPreviewOrderParams = () => ({ - marketId: 'market-123', - outcomeId: 'outcome-456', - outcomeTokenId: 'token-789', - side: Side.BUY, - size: 100, - signer: createPreviewSigner(), - }); - - const createPermit2PreviewProvider = (fakOrdersEnabled: boolean) => - createProvider({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled, - }); - - const mockPreviewOrderWithFees = () => { - mockPreviewOrder.mockResolvedValue({ - fees: { - totalFee: 1, - metamaskFee: 0.5, - providerFee: 0.5, - totalFeePercentage: 1, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - }; - - it('calls previewOrder utility function with correct parameters', async () => { - const provider = createProvider(); - const mockParams = { - ...createPreviewOrderParams(), - amount: 100, - }; - - await provider.previewOrder(mockParams); - - expect(mockPreviewOrder).toHaveBeenCalledWith({ - ...mockParams, - feeCollection: DEFAULT_FEE_COLLECTION_FLAG, - isV2: false, - }); - }); - it('returns FOK orderType by default', async () => { - const provider = createProvider(); - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe('FOK'); - }); - - it('forces preview feeRateBps to zero when CLOB v2 is enabled', async () => { - const provider = createProvider({ predictClobV2Enabled: true }); - mockPreviewOrder.mockResolvedValue({ feeRateBps: '123' }); - - const previewParams = createPreviewOrderParams(); - const result = await provider.previewOrder(previewParams); - - expect(result.feeRateBps).toBe('0'); - expect(mockPreviewOrder).toHaveBeenCalledWith({ - ...previewParams, - feeCollection: DEFAULT_FEE_COLLECTION_FLAG, - isV2: true, - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }); - }); - - it.each([ - { fakOrdersEnabled: true, expectedOrderType: 'FAK' }, - { fakOrdersEnabled: false, expectedOrderType: 'FOK' }, - ] as const)( - 'returns $expectedOrderType orderType when fakOrdersEnabled=$fakOrdersEnabled and permit2 config is active', - async ({ fakOrdersEnabled, expectedOrderType }) => { - mockPreviewOrderWithFees(); - const provider = createPermit2PreviewProvider(fakOrdersEnabled); - - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe(expectedOrderType); - }, - ); - - it('returns FAK orderType when fees are absent and FAK flags are enabled', async () => { - mockPreviewOrder.mockResolvedValue({}); - const provider = createPermit2PreviewProvider(true); - - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe('FAK'); - }); - }); - - describe('API key caching', () => { - function setupApiKeyCachingTest() { - jest.clearAllMocks(); - - const mockAddress1 = '0x1111111111111111111111111111111111111111'; - const mockAddress2 = '0x2222222222222222222222222222222222222222'; - - const mockSigner1 = { - address: mockAddress1, - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - const mockSigner2 = { - address: mockAddress2, - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - - // Setup minimal mocks needed for placeOrder - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockSignPersonalMessage.mockResolvedValue('0xpersonalsignature'); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { success: true, orderId: 'test-order' }, - error: undefined, - }); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - - return { - provider, - mockSigner1, - mockSigner2, - mockAddress1, - mockAddress2, - }; - } - - it('caches API keys by address and reuses them', async () => { - // Arrange - const { provider, mockSigner1 } = setupApiKeyCachingTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner1, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - First call - await provider.placeOrder(orderParams); - - // Act - Second call with same address - await provider.placeOrder(orderParams); - - // Assert - createApiKey should only be called once due to caching - expect(mockCreateApiKey).toHaveBeenCalledTimes(1); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner1.address, - clobVersion: 'v1', - }); - }); - - it('creates separate API keys for different addresses', async () => { - // Arrange - const { provider, mockSigner1, mockSigner2 } = setupApiKeyCachingTest(); - - const preview1 = createMockOrderPreview({ side: Side.BUY }); - const orderParams1 = { - signer: mockSigner1, - providerId: POLYMARKET_PROVIDER_ID, - preview: preview1, - }; - - const preview2 = createMockOrderPreview({ side: Side.SELL }); - const orderParams2 = { - signer: mockSigner2, - providerId: POLYMARKET_PROVIDER_ID, - preview: preview2, - }; - - // Act - await provider.placeOrder(orderParams1); - await provider.placeOrder(orderParams2); - - // Assert - createApiKey should be called twice for different addresses - expect(mockCreateApiKey).toHaveBeenCalledTimes(2); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner1.address, - clobVersion: 'v1', - }); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner2.address, - clobVersion: 'v1', - }); - }); - - it('creates separate cached v2 API keys when the resolved CLOB host changes', async () => { - // Arrange - const { mockSigner1 } = setupApiKeyCachingTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner1, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - let currentFeatureFlags: PredictFeatureFlags = { - ...defaultFeatureFlags, - predictClobV2Enabled: true, - predictClobV2ClobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }; - const provider = new PolymarketProvider({ - getFeatureFlags: () => currentFeatureFlags, - }); - - // Act - First call uses temporary v2 host - await provider.placeOrder(orderParams); - - // Act - Second call uses canonical host for the same address - currentFeatureFlags = { - ...currentFeatureFlags, - predictClobV2ClobBaseUrl: DEFAULT_CLOB_BASE_URL, - }; - await provider.placeOrder(orderParams); - - // Assert - expect(mockCreateApiKey).toHaveBeenCalledTimes(2); - expect(mockCreateApiKey).toHaveBeenNthCalledWith(1, { - address: mockSigner1.address, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - expect(mockCreateApiKey).toHaveBeenNthCalledWith(2, { - address: mockSigner1.address, - clobVersion: 'v2', - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }); - }); - }); - - describe('placeOrder with Safe fee authorization', () => { - it('computes Safe address before creating order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockComputeProxyAddress).toHaveBeenCalledWith(mockSigner.address); - }); - - it('calculates 4% fee from maker amount', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - const expectedFeeAmount = BigInt(40000); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - amount: expectedFeeAmount, - }), - ); - }); - - it('creates fee authorization with correct parameters', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalledWith({ - safeAddress: '0x9999999999999999999999999999999999999999', - signer: mockSigner, - amount: expect.any(BigInt), - to: '0x100c7b833bbd604a77890783439bbb9d65e31de7', - }); - }); - - it('includes feeAuthorization when submitting order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - feeAuthorization: { - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }, - }), - ); - }); - - it('uses collector from fees as recipient', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - to: '0x100c7b833bbd604a77890783439bbb9d65e31de7', - }), - ); - }); - - it('uses Permit2 fee authorization when permit2Enabled and allowance is set', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - safeAddress: '0x9999999999999999999999999999999999999999', - spender: '0x1111111111111111111111111111111111111111', - }), - ); - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - executor: '0x1111111111111111111111111111111111111111', - feeAuthorization: expect.objectContaining({ type: 'safe-permit2' }), - }), - ); - }); - - it('uses Permit2 fee authorization even when Permit2 allowance is not yet set on-chain', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - }); - - it('falls back to Safe fee authorization when permit2Enabled is false', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: false, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); - }); - - it('falls back to Safe fee authorization when executors are missing', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: [], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); - }); - - it('submits FOK order type when fakOrdersEnabled is false', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), - }), - ); - }); - - it('submits FAK order type when Permit2 is used and fakOrdersEnabled is true', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: true, - }); - mockHasAllowances.mockResolvedValue(true); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0xexecutor1'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FAK' }), - }), - ); - }); - - it('submits FOK order type when Permit2 is used but fakOrdersEnabled is false', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: false, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0xexecutor1'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), - }), - ); - }); - - it('submits FAK order type when Permit2 fee auth and allowance are ready', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: true, - }); - mockHasAllowances.mockResolvedValue(true); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0xexecutor1'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FAK' }), - }), - ); - }); - }); - - describe('placeOrder with allowancesTx', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - function setupAllowancesTxTest(overrides?: { - permit2Enabled?: boolean; - hasAllowances?: boolean; - executors?: string[]; - }) { - const result = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: overrides?.permit2Enabled ?? true, - executors: overrides?.executors ?? ['0xexecutor1'], - }, - }); - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - mockHasAllowances.mockResolvedValue(overrides?.hasAllowances ?? false); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ provider: {} }); - return result; - } - - it('attaches allowancesTx when proxy wallet lacks allowances with fees', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - - it('attaches allowancesTx when proxy wallet lacks allowances without fees', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0, - providerFee: 0, - totalFee: 0, - totalFeePercentage: 0, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - - it('attaches allowancesTx regardless of Permit2 on-chain allowance status', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - - it('does not attach allowancesTx when hasAllowances is true', async () => { - const { provider, mockSigner } = setupAllowancesTxTest({ - hasAllowances: true, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: undefined, - }), - ); - }); - - it('does not attach allowancesTx when permit2 is disabled', async () => { - const { provider, mockSigner } = setupAllowancesTxTest({ - permit2Enabled: false, - executors: [], - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: undefined, - }), - ); - expect(getProxyWalletAllowancesTransaction).not.toHaveBeenCalled(); - }); - - it('continues order placement when getProxyWalletAllowancesTransaction throws', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockRejectedValue( - new Error('TX generation failed'), - ); - - const result = await provider.placeOrder({ preview, signer: mockSigner }); - - expect(result.success).toBe(true); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: undefined, - }), - ); - }); - - it('attaches allowancesTx for SELL orders', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.SELL, - fees: undefined, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(getProxyWalletAllowancesTransaction).toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - }); - - describe('placeOrder FAK order type for sell orders', () => { - it('submits FAK order type for sell order without fees when FAK is enabled', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: true, - }); - const preview = createMockOrderPreview({ - side: Side.SELL, - fees: undefined, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FAK' }), - }), - ); - }); - - it('submits FOK order type for sell order without fees when FAK is disabled', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: false, - }); - const preview = createMockOrderPreview({ - side: Side.SELL, - fees: undefined, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), - }), - ); - }); - }); - - describe('placeOrder edge cases', () => { - it('places order without fee authorization when totalFee is zero', async () => { - // Clear mock to ensure clean state for this test - mockCreateSafeFeeAuthorization.mockClear(); - - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0, - providerFee: 0, - totalFee: 0, - totalFeePercentage: 0, - collector: '0x0', - }, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.any(Object), - headers: expect.any(Object), - feeAuthorization: undefined, - }), - ); - }); - - it('places order without fee authorization when fees is undefined', async () => { - mockCreateSafeFeeAuthorization.mockClear(); - - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - feeAuthorization: undefined, - }), - ); - }); - - it('returns error result when submitClobOrder returns no response', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: null, - error: 'Submission failed', - }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Submission failed'); - expect(result.response).toBeUndefined(); - }); - }); - - describe('getActivity', () => { - it('fetches activity and resolves without throwing', async () => { - const provider = createProvider(); - global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => [] }); - const getAccountStateSpy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - await expect( - provider.getActivity({ - address: '0x1234567890123456789012345678901234567890', - }), - ).resolves.toEqual([]); - - expect(getAccountStateSpy).toHaveBeenCalled(); - }); - - it('fetches account state when not cached', async () => { - const provider = createProvider(); - global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => [] }); - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - await provider.getActivity({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(mockComputeProxyAddress).toHaveBeenCalled(); - }); - }); - - describe('claimWinnings', () => { - it('throws error when method is not implemented', () => { - const provider = createProvider(); - - expect(() => provider.claimWinnings()).toThrow('Method not implemented.'); - }); - }); - - describe('prepareClaim', () => { - function setupPrepareClaimTest() { - jest.clearAllMocks(); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockEncodeClaim.mockReturnValue('0xencodedclaim'); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - // Mock getBalance to return a balance above the threshold by default - mockGetBalance.mockResolvedValue(1); - - // Mock computeProxyAddress to return a safe address - mockComputeProxyAddress.mockReturnValue( - '0xSafeAddress123456789012345678901234567890', - ); - - // Mock hasAllowances used by getAccountState - mockHasAllowances.mockResolvedValue(true); - - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - return { provider: createProvider(), signer: mockSigner }; - } - - it('successfully prepares a claim for regular position', async () => { - const { provider, signer } = setupPrepareClaimTest(); - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - const result = await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(result).toEqual({ - chainId: 137, // POLYGON_MAINNET_CHAIN_ID - transactions: [ - { - params: { - data: '0xencodedclaim', - to: '0xConditionalTokensAddress', - value: '0x0', - }, - }, - ], - }); - - // encodeClaim is called internally by getClaimTransaction - // The exact call verification depends on the implementation details - }); - - it('successfully prepares a claim for negRisk position', async () => { - const { provider, signer } = setupPrepareClaimTest(); - const position = { - id: 'position-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-2', - outcomeId: 'outcome-789', - outcomeIndex: 1, - outcome: 'No', - outcomeTokenId: '1', - title: 'Test NegRisk Position', - icon: 'test-icon.png', - amount: 2.0, - price: 0.3, - size: 2.0, - negRisk: true, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.3, - conditionId: 'outcome-789', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.3, - avgPrice: 0.3, - currentValue: 0.3, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - const result = await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(result).toEqual({ - chainId: 137, - transactions: [ - { - params: { - data: '0xencodedclaim', - to: '0xConditionalTokensAddress', - value: '0x0', - }, - }, - ], - }); - - // encodeClaim is called internally by getClaimTransaction - // The exact call verification depends on the implementation details - }); - - it('calls encodeClaim with correct amounts array based on outcomeIndex', async () => { - const { provider, signer } = setupPrepareClaimTest(); - const position = { - id: 'position-3', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-3', - outcomeId: 'outcome-123', - outcomeIndex: 1, - outcome: 'No', - outcomeTokenId: '1', - title: 'Test Position Index 1', - icon: 'test-icon.png', - amount: 0.75, - price: 0.4, - size: 0.75, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.4, - conditionId: 'outcome-123', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.4, - avgPrice: 0.4, - currentValue: 0.4, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ positions: [position], signer }); - - // encodeClaim is called internally by getClaimTransaction - // The exact call verification depends on the implementation details - }); - - it('throws error when signer address is missing', async () => { - jest.clearAllMocks(); - const provider = createProvider(); - const mockSigner = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await expect( - provider.prepareClaim({ - positions: [position], - signer: mockSigner, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('throws error when no positions provided', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.prepareClaim({ - positions: [], - signer: mockSigner, - }), - ).rejects.toThrow('No positions provided for claim'); - }); - - it('throws error when getClaimTransaction returns empty array', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetClaimTransaction.mockResolvedValue([]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await expect( - provider.prepareClaim({ - positions: [position], - signer, - }), - ).rejects.toThrow('No claim transaction generated'); - }); - - it('calls getBalance to check signer collateral balance', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(1); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetBalance).toHaveBeenCalledWith({ address: signer.address }); - }); - - it('does not include transfer when signer balance is above minimum collateral threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(1); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: false, - }), - ); - }); - - it('does not include transfer when signer balance equals minimum collateral threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0.5); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: false, - }), - ); - }); - - it('includes transfer when signer balance is below minimum collateral threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0.3); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: true, - }), - ); - }); - - it('includes transfer when signer balance is zero', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: true, - }), - ); - }); - - it('includes transfer when signer balance is slightly below threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0.49); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: true, - }), - ); - }); - - it('builds a signed Safe claim transaction when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - const signer = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: - '0x1111111111111111111111111111111111111111111111111111111111111111', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - mockGetRawBalance.mockResolvedValue(0n); - - const result = await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(result).toEqual({ - chainId: 137, - transactions: [ - { - params: { - to: '0x1234567890123456789012345678901234567891', - data: '0xsignedsafeexec', - }, - type: 'predictClaim', - }, - ], - }); - expect(mockGetClaimTransaction).not.toHaveBeenCalled(); - expect(mockGetBalance).not.toHaveBeenCalled(); - }); - }); - - describe('isEligible', () => { - const originalFetch = globalThis.fetch; - - function setupIsEligibleTest() { - jest.clearAllMocks(); - return { provider: createProvider() }; - } - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns true when user is not geoblocked', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({ blocked: false, country: 'PT' }), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: true, country: 'PT' }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when user is geoblocked', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({ blocked: true, country: 'US' }), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false, country: 'US' }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when API response does not contain blocked field', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({}), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when API response blocked field is undefined', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({ blocked: undefined }), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when fetch request fails', async () => { - const { provider } = setupIsEligibleTest(); - globalThis.fetch = jest - .fn() - .mockRejectedValue(new Error('Network error')); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when JSON parsing fails', async () => { - const provider = createProvider(); - const mockResponse = { - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('handles non-Error exceptions gracefully', async () => { - const provider = createProvider(); - globalThis.fetch = jest.fn().mockRejectedValue('String error'); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - }); - - it('returns false for malformed API response', async () => { - const provider = createProvider(); - const mockResponse = { - json: jest.fn().mockResolvedValue('invalid response'), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - }); - }); - - describe('getMarketDetails', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetEventLeague.mockReturnValue(null); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - }); - - const mockEvent = { - id: 'market-1', - question: 'Will it rain tomorrow?', - markets: [ - { outcome: 'YES', price: 0.6 }, - { outcome: 'NO', price: 0.4 }, - ], - }; - - const mockParsedMarket = { - id: 'market-1', - question: 'Will it rain tomorrow?', - outcomes: ['YES', 'NO'], - status: 'open', - providerId: POLYMARKET_PROVIDER_ID, - }; - - it('get market details successfully', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValueOnce('nfl'); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(result).toEqual(mockParsedMarket); - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mockEvent], - expect.objectContaining({ - category: 'trending', - teamLookup: expect.any(Function), - }), - ); - }); - - it('throw error when marketId is missing', async () => { - const provider = createProvider(); - - await expect(provider.getMarketDetails({ marketId: '' })).rejects.toThrow( - 'marketId is required', - ); - - await expect( - provider.getMarketDetails({ marketId: null as unknown as string }), - ).rejects.toThrow('marketId is required'); - }); - - it('throw error when getMarketDetailsFromGammaApi fails', async () => { - const provider = createProvider(); - const errorMessage = 'API request failed'; - mockGetMarketDetailsFromGammaApi.mockRejectedValue( - new Error(errorMessage), - ); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow(errorMessage); - }); - - it('throw error when parsePolymarketEvents returns empty array', async () => { - const provider = createProvider(); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([]); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow('Failed to parse market details'); - }); - - it('throw error when parsed market is undefined', async () => { - const provider = createProvider(); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([undefined]); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow('Failed to parse market details'); - }); - - describe('child event fetching', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetEventLeague.mockReturnValue(null); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - }); - - const parentEvent = { - id: 'game-1', - slug: 'nfl-kc-buf-2026-01-01', - question: 'Who wins the game?', - tags: [{ slug: 'games' }, { slug: 'nfl' }], - }; - const requestedChildEvent = { - id: 'child-player-props', - slug: 'nfl-kc-buf-2026-01-01-player-props', - parentEventId: 'game-1', - question: 'Player props?', - tags: [{ slug: 'games' }, { slug: 'nfl' }], - teams: [{ league: 'nfl' }, { league: 'nfl' }], - }; - const childEvent1 = { - id: 'child-player-props', - slug: 'nfl-kc-buf-2026-01-01-player-props', - question: 'Player props?', - }; - const childEvent2 = { - id: 'child-halftime', - slug: 'nfl-kc-buf-2026-01-01-halftime-result', - question: 'Halftime result?', - }; - const mergedEvent = { - id: 'game-1', - question: 'Who wins the game?', - markets: [ - { outcome: 'Team A', price: 0.6 }, - { outcome: 'Team B', price: 0.4 }, - { outcome: 'Over', price: 0.5 }, - { outcome: 'Under', price: 0.5 }, - ], - }; - - it('promotes suffixed child events to their parent when the parent is an extended sports game', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(requestedChildEvent); - mockFetchChildEventsFromGammaApi.mockResolvedValue([ - parentEvent, - childEvent1, - childEvent2, - ]); - mockMergeChildEventsIntoParent.mockReturnValue(mergedEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: requestedChildEvent.id }); - - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledTimes(1); - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ - marketId: requestedChildEvent.id, - }); - - expect(mockFetchChildEventsFromGammaApi).toHaveBeenCalledWith({ - parentEventId: 'game-1', - }); - expect(mockMergeChildEventsIntoParent).toHaveBeenCalledWith([ - parentEvent, - childEvent1, - childEvent2, - ]); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mergedEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('does not fetch child events for non-sports event', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(parentEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockFetchChildEventsFromGammaApi).not.toHaveBeenCalled(); - }); - - it('keeps the requested child event when the parent league is not extended', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: [], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => - Promise.resolve( - marketId === requestedChildEvent.id - ? requestedChildEvent - : parentEvent, - ), - ); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: requestedChildEvent.id }); - - expect(mockFetchChildEventsFromGammaApi).not.toHaveBeenCalled(); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [requestedChildEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('falls back to the requested event when child fetch fails', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(requestedChildEvent); - mockFetchChildEventsFromGammaApi.mockRejectedValue( - new Error('Network error'), - ); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: requestedChildEvent.id, - }); - - expect(result).toEqual(mockParsedMarket); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [requestedChildEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('uses event.id to fetch children when the event has no parentEventId', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(parentEvent); - mockFetchChildEventsFromGammaApi.mockResolvedValue([ - parentEvent, - childEvent1, - childEvent2, - ]); - mockMergeChildEventsIntoParent.mockReturnValue(mergedEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: parentEvent.id }); - - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledTimes(1); - expect(mockFetchChildEventsFromGammaApi).toHaveBeenCalledWith({ - parentEventId: 'game-1', - }); - expect(mockMergeChildEventsIntoParent).toHaveBeenCalledWith([ - parentEvent, - childEvent1, - childEvent2, - ]); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mergedEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('does not fetch child events when getEventLeague returns null', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(parentEvent); - mockGetEventLeague.mockReturnValue(null); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockFetchChildEventsFromGammaApi).not.toHaveBeenCalled(); - }); - }); - }); - - describe('getMarketsByIds', () => { - const createMockEvent = (id: string) => ({ - id, - question: `Question for ${id}?`, - markets: [ - { outcome: 'YES', price: 0.6 }, - { outcome: 'NO', price: 0.4 }, - ], - }); - - const createMockParsedMarket = (id: string) => ({ - id, - question: `Question for ${id}?`, - outcomes: ['YES', 'NO'], - status: 'open', - providerId: POLYMARKET_PROVIDER_ID, - }); - - beforeEach(() => { - mockGetMarketDetailsFromGammaApi.mockReset(); - mockParsePolymarketEvents.mockReset(); - }); - - it('returns empty array when marketIds is empty', async () => { - const provider = createProvider(); - - const result = await provider.getMarketsByIds([]); - - expect(result).toEqual([]); - expect(mockGetMarketDetailsFromGammaApi).not.toHaveBeenCalled(); - }); - - it('returns empty array when marketIds is undefined', async () => { - const provider = createProvider(); - - const result = await provider.getMarketsByIds( - undefined as unknown as string[], - ); - - expect(result).toEqual([]); - }); - - it('fetches multiple markets in parallel and preserves order', async () => { - const provider = createProvider(); - const marketIds = ['market-1', 'market-2', 'market-3']; - - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => - Promise.resolve(createMockEvent(marketId)), - ); - mockParsePolymarketEvents.mockImplementation((events) => - events.map((event: { id: string }) => createMockParsedMarket(event.id)), - ); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toHaveLength(3); - expect(result[0].id).toBe('market-1'); - expect(result[1].id).toBe('market-2'); - expect(result[2].id).toBe('market-3'); - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledTimes(3); - }); - - it('filters out failed market fetches gracefully', async () => { - const provider = createProvider(); - const marketIds = ['market-1', 'market-fail', 'market-3']; - - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => { - if (marketId === 'market-fail') { - return Promise.reject(new Error('API error')); - } - return Promise.resolve(createMockEvent(marketId)); - }); - mockParsePolymarketEvents.mockImplementation((events) => - events.map((event: { id: string }) => createMockParsedMarket(event.id)), - ); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe('market-1'); - expect(result[1].id).toBe('market-3'); - }); - - it('returns empty array when all market fetches fail', async () => { - const provider = createProvider(); - const marketIds = ['market-1', 'market-2']; - - mockGetMarketDetailsFromGammaApi.mockRejectedValue( - new Error('API error'), - ); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toEqual([]); - }); - - it('fetches single market correctly', async () => { - const provider = createProvider(); - const marketIds = ['market-1']; - - mockGetMarketDetailsFromGammaApi.mockResolvedValue( - createMockEvent('market-1'), - ); - mockParsePolymarketEvents.mockReturnValue([ - createMockParsedMarket('market-1'), - ]); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('market-1'); - }); - - it('calls getMarketDetails for each market id', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const marketIds = ['market-1', 'market-2']; - - const getMarketDetailsSpy = jest.spyOn(provider, 'getMarketDetails'); - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => - Promise.resolve(createMockEvent(marketId)), - ); - mockParsePolymarketEvents.mockImplementation((events) => - events.map((event: { id: string }) => createMockParsedMarket(event.id)), - ); - - await provider.getMarketsByIds(marketIds); - - expect(getMarketDetailsSpy).toHaveBeenCalledTimes(2); - expect(getMarketDetailsSpy).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - expect(getMarketDetailsSpy).toHaveBeenCalledWith({ - marketId: 'market-2', - }); - - getMarketDetailsSpy.mockRestore(); - }); - - it('calls getMarketDetails without extra params by default', async () => { - const provider = createProvider(); - const marketIds = ['market-1']; - - const getMarketDetailsSpy = jest.spyOn(provider, 'getMarketDetails'); - mockGetMarketDetailsFromGammaApi.mockResolvedValue( - createMockEvent('market-1'), - ); - mockParsePolymarketEvents.mockReturnValue([ - createMockParsedMarket('market-1'), - ]); - - await provider.getMarketsByIds(marketIds); - - expect(getMarketDetailsSpy).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - - getMarketDetailsSpy.mockRestore(); - }); - }); - - describe('getUnrealizedPnL', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it('successfully fetches unrealized P&L data', async () => { - const provider = createProvider(); - const mockUnrealizedPnL = [ - { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: -7.337110036077004, - percentUpnl: -31.32290842628039, - }, - ]; - - (computeProxyAddress as jest.Mock).mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockUnrealizedPnL), - }); - - const result = await provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(result).toEqual(mockUnrealizedPnL[0]); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://data-api.polymarket.com/upnl?user=0x9999999999999999999999999999999999999999', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - }); - - it('throws error when API response is not ok', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 404, - }); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('Failed to fetch unrealized P&L'); - }); - - it('returns undefined when API returns empty array', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - const result = await provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(result).toBeUndefined(); - }); - - it('throws error when API returns non-array response', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({}), - }); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('No unrealized P&L data found'); - }); - - it('handles network errors', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockRejectedValue( - new Error('Network error'), - ); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('Network error'); - }); - - it('handles JSON parsing errors', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('Invalid JSON'); - }); - - it('uses default address when not provided', async () => { - const provider = createProvider(); - const mockUnrealizedPnL = [ - { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: 0, - percentUpnl: 0, - }, - ]; - - (computeProxyAddress as jest.Mock).mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockUnrealizedPnL), - }); - - await provider.getUnrealizedPnL({ - address: '0x0000000000000000000000000000000000000000', - }); - - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://data-api.polymarket.com/upnl?user=0x9999999999999999999999999999999999999999', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - }); - - it('fetches account state when not cached', async () => { - const provider = createProvider(); - const mockUnrealizedPnL = [ - { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: 5.5, - percentUpnl: 10.5, - }, - ]; - - (computeProxyAddress as jest.Mock).mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockUnrealizedPnL), - }); - - const result = await provider.getUnrealizedPnL({ - address: '0xNewAddress', - }); - - expect(result).toEqual(mockUnrealizedPnL[0]); - expect(computeProxyAddress).toHaveBeenCalled(); - }); - }); - - describe('getPriceHistory', () => { - const mockHistoryData = { - history: [ - { t: 1234567890, p: 0.45 }, - { t: 1234567900, p: 0.47 }, - { t: 1234567910, p: 0.49 }, - ], - }; - - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('get price history successfully', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567900, price: 0.47 }, - { timestamp: 1234567910, price: 0.49 }, - ]); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1', - { method: 'GET' }, - ); - }); - - it('include fidelity parameter in request', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getPriceHistory({ - marketId: 'market-1', - fidelity: 100, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1&fidelity=100', - { method: 'GET' }, - ); - }); - - it('include interval parameter in request', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getPriceHistory({ - marketId: 'market-1', - interval: PredictPriceHistoryInterval.ONE_HOUR, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1&interval=1h', - { method: 'GET' }, - ); - }); - - it('include both fidelity and interval parameters', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getPriceHistory({ - marketId: 'market-1', - fidelity: 50, - interval: PredictPriceHistoryInterval.ONE_DAY, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1&fidelity=50&interval=1d', - { method: 'GET' }, - ); - }); - - it('throw error when marketId is missing', async () => { - const provider = createProvider(); - - await expect(provider.getPriceHistory({ marketId: '' })).rejects.toThrow( - 'marketId parameter is required', - ); - - await expect( - provider.getPriceHistory({ marketId: null as unknown as string }), - ).rejects.toThrow('marketId parameter is required'); - }); - - it('return empty array when response is not ok', async () => { - const provider = createProvider(); - const mockResponse = { - ok: false, - status: 404, - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('return empty array when fetch throws error', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('return empty array when response has no history array', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({}), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('return empty array when history is not an array', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ history: 'not-an-array' }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('filter out entries with missing timestamp', async () => { - const provider = createProvider(); - const mockData = { - history: [ - { t: 1234567890, p: 0.45 }, - { p: 0.47 }, // Missing timestamp - { t: 1234567910, p: 0.49 }, - ], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567910, price: 0.49 }, - ]); - }); - - it('filter out entries with missing price', async () => { - const provider = createProvider(); - const mockData = { - history: [ - { t: 1234567890, p: 0.45 }, - { t: 1234567900 }, // Missing price - { t: 1234567910, p: 0.49 }, - ], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567910, price: 0.49 }, - ]); - }); - - it('filter out entries with non-numeric timestamp or price', async () => { - const provider = createProvider(); - const mockData = { - history: [ - { t: 1234567890, p: 0.45 }, - { t: 'invalid', p: 0.47 }, - { t: 1234567900, p: 'invalid' }, - { t: null, p: 0.48 }, - { t: 1234567910, p: null }, - { t: 1234567920, p: 0.49 }, - ], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567920, price: 0.49 }, - ]); - }); - - it('return empty array when history has no valid entries', async () => { - const provider = createProvider(); - const mockData = { - history: [{ t: 'invalid', p: 'invalid' }, { t: null, p: null }, {}], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('handle JSON parsing error', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('handle empty history array', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ history: [] }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('returns empty array when non-Error exception is thrown', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockRejectedValue('String error'); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - }); - - describe('getCryptoTargetPrice', () => { - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns openPrice on successful fetch', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - openPrice: 82615.22, - closePrice: 82352.85, - timestamp: 1700000000000, - completed: true, - incomplete: false, - cached: false, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getCryptoTargetPrice({ - symbol: 'BTC', - eventStartTime: '2025-01-01T00:00:00Z', - variant: 'up', - endDate: '2025-01-02', - }); - - expect(result).toBe(82615.22); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining( - 'polymarket.com/api/crypto/crypto-price?symbol=BTC', - ), - ); - }); - - it('returns null when API returns non-ok response', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, - }); - - const result = await provider.getCryptoTargetPrice({ - symbol: 'BTC', - eventStartTime: '2025-01-01T00:00:00Z', - variant: 'up', - endDate: '2025-01-02', - }); - - expect(result).toBeNull(); - }); - - it('returns null when response has unexpected shape', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ value: 'not-a-number' }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getCryptoTargetPrice({ - symbol: 'BTC', - eventStartTime: '2025-01-01T00:00:00Z', - variant: 'up', - endDate: '2025-01-02', - }); - - expect(result).toBeNull(); - }); - - it('encodes query parameters in the URL', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ openPrice: 100 }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getCryptoTargetPrice({ - symbol: 'ETH/USD', - eventStartTime: '2025-01-01 00:00:00', - variant: 'up', - endDate: '2025-01-02', - }); - - const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; - expect(calledUrl).toContain('symbol=ETH%2FUSD'); - expect(calledUrl).toContain('eventStartTime=2025-01-01%2000%3A00%3A00'); - }); - }); - - describe('getPrices', () => { - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('get prices successfully', async () => { - const provider = createProvider(); - const mockPricesData = { - 'token-1': { BUY: '0.65', SELL: '0.64' }, - 'token-2': { BUY: '0.35', SELL: '0.34' }, - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockPricesData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - entry: { buy: 0.65, sell: 0.64 }, - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - entry: { buy: 0.35, sell: 0.34 }, - }, - ], - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify([ - { token_id: 'token-1', side: Side.BUY }, - { token_id: 'token-1', side: Side.SELL }, - { token_id: 'token-2', side: Side.BUY }, - { token_id: 'token-2', side: Side.SELL }, - ]), - }, - ); - }); - - it('convert string prices to numbers correctly', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.123456', SELL: '0.123' }, - 'token-2': { BUY: '0.987', SELL: '0.987654' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.123456); - expect(result.results[0].entry.sell).toBe(0.123); - expect(result.results[1].entry.buy).toBe(0.987); - expect(result.results[1].entry.sell).toBe(0.987654); - }); - - it('handle multiple sides for same token', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[0].entry.sell).toBe(0.64); - }); - - it('throw error when queries is empty', async () => { - const provider = createProvider(); - - await expect( - provider.getPrices({ - queries: [], - }), - ).rejects.toThrow('queries parameter is required and must not be empty'); - }); - - it('return empty object when response is not ok', async () => { - const provider = createProvider(); - const mockResponse = { - ok: false, - status: 400, - statusText: 'Bad Request', - text: jest.fn().mockResolvedValue('Bad Request'), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('return empty object when fetch fails', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('return empty object when invalid JSON response', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('handle non-numeric price values', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65' }, - 'token-2': { BUY: 'invalid' }, - 'token-3': { BUY: '0.35' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - { - marketId: 'market-3', - outcomeId: 'outcome-3', - outcomeTokenId: 'token-3', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[1].entry.buy).toBeNaN(); - expect(result.results[2].entry.buy).toBe(0.35); - }); - - it('handle null or undefined prices', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - 'token-2': { BUY: null, SELL: null }, - 'token-3': {}, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - { - marketId: 'market-3', - outcomeId: 'outcome-3', - outcomeTokenId: 'token-3', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[1].entry.buy).toBe(0); - expect(result.results[2].entry.buy).toBe(0); - }); - - it('return empty object when response body is null', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(null), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('handle BUY side correctly', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result.results[0].entry).toHaveProperty('buy'); - expect(result.results[0].entry.buy).toBe(0.65); - }); - - it('handle SELL side correctly', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result.results[0].entry).toHaveProperty('sell'); - expect(result.results[0].entry.sell).toBe(0.64); - }); - - it('handle multiple tokens with different sides', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - 'token-2': { BUY: '0.36', SELL: '0.34' }, - 'token-3': { BUY: '0.35', SELL: '0.33' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - { - marketId: 'market-3', - outcomeId: 'outcome-3', - outcomeTokenId: 'token-3', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[1].entry.sell).toBe(0.34); - expect(result.results[2].entry.buy).toBe(0.35); - }); - }); - - describe('prepareDeposit', () => { - const mockSigner = { - address: '0x123', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - beforeEach(() => { - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (generateTransferData as jest.Mock).mockReturnValue('0xtransferData'); - }); - - it('prepares deploy and allowance transactions when wallet not deployed', async () => { - // Given a wallet that is not deployed - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getBalance as jest.Mock).mockResolvedValue(0); - (getDeployProxyWalletTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xFactory', data: '0xdeploy' }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then all three transactions are included - expect(result.transactions).toHaveLength(3); - expect(result.transactions[0].params.data).toBe('0xdeploy'); - expect(result.transactions[1].params.data).toBe('0xallowances'); - expect(result.transactions[2].type).toBe('predictDeposit'); - expect(result.chainId).toBe('0x89'); - }); - - it('prepares only allowance transaction when wallet deployed but no allowances', async () => { - // Given a deployed wallet without allowances - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getBalance as jest.Mock).mockResolvedValue(100); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then only allowance and deposit transactions are included - expect(result.transactions).toHaveLength(2); - expect(result.transactions[0].params.data).toBe('0xallowances'); - expect(result.transactions[1].type).toBe('predictDeposit'); - }); - - it('passes Permit2 spender when creating allowance transaction and permit2Enabled is true', async () => { - const provider = createProvider({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - }, - }); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.prepareDeposit({ signer: mockSigner }); - - expect(getProxyWalletAllowancesTransaction).toHaveBeenCalledWith({ - signer: mockSigner, - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - }); - - it('prepares only deposit transaction when wallet deployed and has allowances', async () => { - // Given a fully set up wallet - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (getBalance as jest.Mock).mockResolvedValue(100); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then only deposit transaction is included - expect(result.transactions).toHaveLength(1); - expect(result.transactions[0].type).toBe('predictDeposit'); - }); - - it('throws error when deploy transaction fails', async () => { - // Given deploy transaction returns undefined - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getBalance as jest.Mock).mockResolvedValue(0); - (getDeployProxyWalletTransaction as jest.Mock).mockResolvedValue( - undefined, - ); - - // When preparing deposit - // Then it throws an error - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow('Failed to get deploy proxy wallet transaction params'); - }); - - it('uses correct collateral address in deposit transaction', async () => { - // Given a fully set up wallet - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (getBalance as jest.Mock).mockResolvedValue(100); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then deposit transaction targets collateral contract - expect(result.transactions[0].params.to).toBeDefined(); - expect(generateTransferData).toHaveBeenCalledWith('transfer', { - toAddress: '0xSafeAddress', - amount: '0x0', - }); - }); - - it('throws error when signer address is missing', async () => { - const provider = createProvider(); - const mockSignerWithoutAddress = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.prepareDeposit({ - signer: mockSignerWithoutAddress, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('throws error when deploy transaction has no params', async () => { - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getDeployProxyWalletTransaction as jest.Mock).mockResolvedValue({}); - - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow('Invalid deploy transaction: missing params'); - }); - - it('throws error when allowance transaction has no params', async () => { - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({}); - - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow('Invalid allowance transaction: missing params'); - }); - - it('throws error when generateTransferData returns undefined', async () => { - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (generateTransferData as jest.Mock).mockReturnValue(undefined); - - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow( - 'Failed to generate transfer data for deposit transaction', - ); - }); - - it('adds a maintenance Safe transaction instead of v1 allowances when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - mockGetRawBalance.mockResolvedValue(1n); - - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - expect(result.transactions).toHaveLength(2); - expect(result.transactions[0]).toEqual({ - params: { - to: USDC_E_ADDRESS, - data: '0xtransferData', - }, - type: 'predictDeposit', - }); - expect(result.transactions[1]).toEqual({ - params: { - to: '0x1234567890123456789012345678901234567891', - data: '0xsignedsafeexec', - }, - type: 'contractInteraction', - }); - expect(getProxyWalletAllowancesTransaction).not.toHaveBeenCalled(); - }); - }); - - describe('Rate Limiting', () => { - describe('previewOrder with rate limiting', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const setupPreviewOrderMock = () => { - mockPreviewOrder.mockResolvedValue({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - timestamp: Date.now(), - side: Side.BUY, - sharePrice: 0.5, - maxAmountSpent: 100, - minAmountReceived: 200, - slippage: 0.005, - tickSize: 0.01, - minOrderSize: 1, - negRisk: false, - fees: { - metamaskFee: 0.5, - providerFee: 0.5, - totalFee: 1, - totalFeePercentage: 1, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - }; - - it('sets rateLimited for SELL orders after BUY order', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - // Place a BUY order first to set rate limit state - const preview = createMockOrderPreview({ side: Side.BUY }); - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Now try to preview a SELL order - should also be rate limited - const sellPreview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.SELL, - size: 10, - signer: mockSigner, - }); - - expect(sellPreview.rateLimited).toBe(true); - }); - - it('does not set rateLimited when address has never placed an order', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - const preview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.BUY, - size: 10, - signer: mockSigner, - }); - - expect(preview.rateLimited).toBeUndefined(); - }); - - it('sets rateLimited to true when BUY order is rate limited', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - // Place a BUY order first to set rate limit state - const preview = createMockOrderPreview({ side: Side.BUY }); - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Try to preview another BUY order immediately - should be rate limited - const secondPreview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.BUY, - size: 10, - signer: mockSigner, - }); - - expect(secondPreview.rateLimited).toBe(true); - }); - - it('sets rateLimited to true when BUY order is in progress', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - mockSubmitClobOrder.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - success: true, - response: { - makingAmount: '1000000', - orderID: 'order-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }); - }, 100); - }), - ); - - const preview = createMockOrderPreview({ side: Side.BUY }); - const placeOrderPromise = provider.placeOrder({ - signer: mockSigner, - preview, - }); - - const secondPreview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.BUY, - size: 10, - signer: mockSigner, - }); - - expect(secondPreview.rateLimited).toBe(true); - - await placeOrderPromise; - }); - }); - - describe('placeOrder rate limiting behavior', () => { - it('successfully places BUY order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - - const preview = createMockOrderPreview({ side: Side.BUY }); - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(true); - }); - - it('successfully places SELL order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - - const preview = createMockOrderPreview({ side: Side.SELL }); - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(true); - }); - - it('handles failed BUY orders', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: 'Order submission failed', - }); - - const preview = createMockOrderPreview({ side: Side.BUY }); - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - }); - - it('handles different addresses independently', async () => { - const { provider } = setupPlaceOrderTest(); - const mockSigner1 = { - address: '0x1111111111111111111111111111111111111111', - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - const mockSigner2 = { - address: '0x2222222222222222222222222222222222222222', - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - - const preview = createMockOrderPreview({ side: Side.BUY }); - const result1 = await provider.placeOrder({ - signer: mockSigner1, - preview, - }); - - const result2 = await provider.placeOrder({ - signer: mockSigner2, - preview, - }); - - expect(result1.success).toBe(true); - expect(result2.success).toBe(true); - }); - }); - }); - - describe('getAccountState', () => { - beforeEach(() => { - jest.clearAllMocks(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - }); - - it('returns account state for an undeployed wallet', async () => { - // Given an undeployed wallet - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - - // When getting account state - const result = await provider.getAccountState({ - ownerAddress: '0x123', - }); - - // Then correct state is returned - expect(result).toEqual({ - address: '0xSafeAddress', - isDeployed: false, - hasAllowances: false, - }); - }); - - it('returns account state for a deployed wallet with allowances', async () => { - // Given a deployed wallet with allowances - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - // When getting account state - const result = await provider.getAccountState({ - ownerAddress: '0x456', - }); - - // Then correct state is returned - expect(result).toEqual({ - address: '0xSafeAddress', - isDeployed: true, - hasAllowances: true, - }); - }); - - it('caches account state by owner address', async () => { - // Given an account state check - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - // When getting account state twice - await provider.getAccountState({ ownerAddress: '0x123' }); - await provider.getAccountState({ ownerAddress: '0x123' }); - - // Then Safe address is only computed once - expect(computeProxyAddress).toHaveBeenCalledTimes(1); - }); - - it('computes Safe address for each unique owner', async () => { - // Given multiple owner addresses - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - // When getting account state for different owners - await provider.getAccountState({ ownerAddress: '0x123' }); - await provider.getAccountState({ ownerAddress: '0x456' }); - - // Then Safe address is computed for each owner - expect(computeProxyAddress).toHaveBeenCalledTimes(2); - expect(computeProxyAddress).toHaveBeenCalledWith('0x123'); - expect(computeProxyAddress).toHaveBeenCalledWith('0x456'); - }); - - it('calls all required functions in parallel', async () => { - // Given account state check - const provider = createProvider(); - const isDeployedPromise = Promise.resolve(true); - const hasAllowancesPromise = Promise.resolve(true); - - (isSmartContractAddress as jest.Mock).mockReturnValue(isDeployedPromise); - (hasAllowances as jest.Mock).mockReturnValue(hasAllowancesPromise); - - // When getting account state - await provider.getAccountState({ ownerAddress: '0x123' }); - - // Then all functions are called - expect(isSmartContractAddress).toHaveBeenCalledWith( - '0xSafeAddress', - '0x89', - ); - expect(hasAllowances).toHaveBeenCalledWith({ - address: '0xSafeAddress', - extraUsdcSpenders: [], - }); - }); - - it('passes Permit2 spender to hasAllowances when permit2Enabled is true', async () => { - const provider = createProvider({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - }, - }); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - await provider.getAccountState({ ownerAddress: '0x123' }); - - expect(hasAllowances).toHaveBeenCalledWith({ - address: '0xSafeAddress', - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - }); - - it('throws error when ownerAddress is missing', async () => { - const provider = createProvider(); - - await expect( - provider.getAccountState({ ownerAddress: '' }), - ).rejects.toThrow('Owner address is required'); - }); - - it('throws error when computeProxyAddress fails', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockImplementation(() => { - throw new Error('Failed to compute'); - }); - - await expect( - provider.getAccountState({ ownerAddress: '0x123' }), - ).rejects.toThrow('Failed to compute safe address'); - }); - - it('throws error when computeProxyAddress returns empty string', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue(''); - - await expect( - provider.getAccountState({ ownerAddress: '0x123' }), - ).rejects.toThrow('Failed to get safe address'); - }); - - it('throws error when checking account state fails', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockRejectedValue( - new Error('Network error'), - ); - - await expect( - provider.getAccountState({ ownerAddress: '0x123' }), - ).rejects.toThrow('Failed to check account state'); - }); - }); - - describe('getBalance', () => { - it('returns balance for the given address', async () => { - // Given a provider - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (getBalance as jest.Mock).mockResolvedValue(123.45); - - // When getting balance - const result = await provider.getBalance({ - address: '0x1234567890123456789012345678901234567890', - }); - - // Then balance is returned - expect(result).toBe(123.45); - expect(getBalance).toHaveBeenCalledWith({ address: '0xSafeAddress' }); - }); - - it('throws error when address is missing', async () => { - const provider = createProvider(); - - await expect(provider.getBalance({ address: '' })).rejects.toThrow( - 'address is required', - ); - }); - - it('uses cached address when available', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (getBalance as jest.Mock).mockResolvedValue(100); - - const userAddress = '0x1234567890123456789012345678901234567890'; - - await provider.getAccountState({ ownerAddress: userAddress }); - jest.clearAllMocks(); - - await provider.getBalance({ - address: userAddress, - }); - - expect(computeProxyAddress).not.toHaveBeenCalled(); - }); - - it('aggregates Safe USDC.e and pUSD balances when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - mockGetBalance.mockResolvedValueOnce(12.5).mockResolvedValueOnce(7.25); - - const result = await provider.getBalance({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(result).toBe(19.75); - expect(mockGetBalance).toHaveBeenNthCalledWith(1, { - address: '0xSafeAddress', - tokenAddress: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - }); - expect(mockGetBalance).toHaveBeenNthCalledWith(2, { - address: '0xSafeAddress', - tokenAddress: '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', - }); - }); - }); - - describe('prepareWithdraw', () => { - it('prepares withdraw transaction successfully', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - jest - .spyOn(PolymarketProvider.prototype, 'getAccountState') - .mockResolvedValue({ - address: '0xSafeAddress', - isDeployed: true, - hasAllowances: true, - }); - - const result = await provider.prepareWithdraw({ - signer: mockSigner, - }); - - expect(result).toHaveProperty('chainId'); - expect(result).toHaveProperty('transaction'); - expect(result).toHaveProperty('predictAddress'); - expect(result.predictAddress).toBe('0xSafeAddress'); - }); - - it('throws error when signer address is missing in prepareWithdraw', async () => { - const provider = createProvider(); - const mockSigner = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.prepareWithdraw({ - signer: mockSigner, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('fetches account state when not cached', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - const result = await provider.prepareWithdraw({ - signer: mockSigner, - }); - - expect(result.predictAddress).toBe('0xSafeAddress'); - expect(mockComputeProxyAddress).toHaveBeenCalled(); - }); - - it('prepares a legacy USDC.e edit transaction when CLOB v2 is enabled', async () => { - const provider = createProvider({ predictClobV2Enabled: true }); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - const result = await provider.prepareWithdraw({ - signer: mockSigner, - }); - - expect(result.predictAddress).toBe('0xSafeAddress'); - expect(result.transaction.params.to).toBe(USDC_E_ADDRESS); - }); - }); - - describe('prepareWithdrawConfirmation', () => { - it('prepares withdraw confirmation successfully', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - - const result = await provider.signWithdraw({ - callData: '0xcalldata', - signer: mockSigner, - }); - - expect(result).toHaveProperty('callData'); - expect(result).toHaveProperty('amount'); - }); - - it('throws error when signer address is missing in signWithdraw', async () => { - const provider = createProvider(); - const mockSigner = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.signWithdraw({ - callData: '0xcalldata', - signer: mockSigner, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('builds a signed Safe withdraw execution when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - mockGetRawBalance - .mockResolvedValueOnce(0n) - .mockResolvedValueOnce(1_000_000n); - - const result = await provider.signWithdraw({ - callData: - '0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000f4240', - signer: mockSigner, - }); - - expect(result).toEqual({ - callData: '0xsignedsafeexec', - amount: 1, - }); - expect(getWithdrawTransactionCallData).not.toHaveBeenCalled(); - }); - - it('throws when Safe pUSD is insufficient for fallback v2 withdraw', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - mockGetRawBalance - .mockResolvedValueOnce(0n) - .mockResolvedValueOnce(999_999n); - - await expect( - provider.signWithdraw({ - callData: - '0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000f4240', - signer: mockSigner, - }), - ).rejects.toThrow('Insufficient Safe pUSD balance for fallback withdraw'); - }); - }); - - describe('fetchActivity', () => { - const provider = createProvider(); - - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - it('throws when address is missing', async () => { - await expect(provider.getActivity({ address: '' })).rejects.toThrow(); - }); - - it('calls fetch with derived predictAddress and parses activity', async () => { - const jsonData = [{ id: 'x1' }]; - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: () => jsonData, - }); - - // Mock getAccountState used to derive predict address - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - - expect(spy).toHaveBeenCalledWith({ ownerAddress: '0xuser' }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('user=0xSAFE'), - expect.objectContaining({ method: 'GET' }), - ); - expect(Array.isArray(result)).toBe(true); - }); - - it('returns empty array on non-ok response', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - json: () => ({}), - }); - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - expect(spy).toHaveBeenCalled(); - expect(result).toEqual([]); - }); - }); - - describe('Activity', () => { - const provider = createProvider(); - - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - it('throws when address is missing', async () => { - await expect(provider.getActivity({ address: '' })).rejects.toThrow(); - }); - - it('calls fetch with derived predictAddress and parses activity', async () => { - const jsonData = [{ id: 'x1' }]; - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: () => jsonData, - }); - - // Mock getAccountState used to derive predict address - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - - expect(spy).toHaveBeenCalledWith({ ownerAddress: '0xuser' }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('user=0xSAFE'), - expect.objectContaining({ method: 'GET' }), - ); - expect(Array.isArray(result)).toBe(true); - }); - - it('returns empty array on non-ok response', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - json: () => ({}), - }); - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - expect(spy).toHaveBeenCalled(); - expect(result).toEqual([]); - }); - }); - - describe('optimistic position updates', () => { - let originalFetch: typeof fetch | undefined; - - beforeEach(() => { - originalFetch = globalThis.fetch as typeof fetch | undefined; - jest.clearAllMocks(); - }); - - afterEach(() => { - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - describe('confirmClaim', () => { - it('marks claimed positions for optimistic removal', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }), - ]; - - // Mock fetch for getPositions - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - { - id: 'position-2', - market: 'market-1', - size: '20', - value: '200', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }, - ]); - - // Act - provider.confirmClaim({ positions: mockPositions, signer: mockSigner }); - - // Assert - subsequent getPositions should filter out claimed positions - const result = await provider.getPositions({ address: mockAddress }); - expect(result).toHaveLength(0); - }); - - it('handles single position claim', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - const mockPosition = createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }); - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }, - ]); - - // Act - provider.confirmClaim({ - positions: [mockPosition], - signer: mockSigner, - }); - - // Assert - const result = await provider.getPositions({ address: mockAddress }); - expect(result).toHaveLength(0); - }); - }); - - describe('createOptimisticPositionFromPreview', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('creates optimistic position for a new position using preview data', async () => { - const { provider, mockAddress, mockFetch } = - setupOptimisticUpdateTest(); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokenId: 'token-456', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - sharePrice: 0.5, - maxAmountSpent: 10, - minAmountReceived: 20, - }); - - await provider.createOptimisticPositionFromPreview({ - address: mockAddress, - preview, - }); - - const positions = await provider.getPositions({ - address: mockAddress, - }); - - expect(positions).toHaveLength(1); - expect(positions[0]).toEqual( - expect.objectContaining({ - marketId: 'market-1', - outcomeTokenId: 'token-456', - optimistic: true, - }), - ); - }); - - it('updates existing position when one already exists', async () => { - const { provider, mockAddress, mockFetch } = - setupOptimisticUpdateTest(); - - const existingPosition = createMockPosition({ - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - amount: 10, - size: 10, - initialValue: 5, - }); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([existingPosition]); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - sharePrice: 0.5, - maxAmountSpent: 5, - minAmountReceived: 10, - }); - - await provider.createOptimisticPositionFromPreview({ - address: mockAddress, - preview, - }); - - mockParsePolymarketPositions.mockResolvedValue([existingPosition]); - - const positions = await provider.getPositions({ - address: mockAddress, - }); - - const optimisticPosition = positions.find( - (p) => p.outcomeTokenId === 'token-456', - ); - expect(optimisticPosition?.optimistic).toBe(true); - expect(optimisticPosition?.amount).toBe(20); - expect(optimisticPosition?.initialValue).toBe(10); - }); - }); - - describe('clearOptimisticPosition', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('removes optimistic position so it no longer appears in getPositions', async () => { - const { provider, mockAddress, mockFetch } = - setupOptimisticUpdateTest(); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokenId: 'token-456', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - }); - - await provider.createOptimisticPositionFromPreview({ - address: mockAddress, - preview, - }); - - provider.clearOptimisticPosition(mockAddress, 'token-456'); - - const positions = await provider.getPositions({ - address: mockAddress, - }); - - expect(positions).toHaveLength(0); - }); - - it('is a no-op when no optimistic position exists for the address', () => { - const provider = createProvider(); - - expect(() => { - provider.clearOptimisticPosition('0xunknown', 'token-1'); - }).not.toThrow(); - }); - }); - - describe('getPositions with optimistic removal filtering', () => { - it('filters out positions marked for optimistic removal', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - // First, mark position-2 (token-2) for removal - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - // Mock fetch to return 3 positions - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - { - id: 'position-2', - market: 'market-1', - size: '20', - value: '200', - }, - { - id: 'position-3', - market: 'market-1', - size: '30', - value: '300', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - should return only 2 positions (position-2 filtered out) - expect(result).toHaveLength(2); - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-1' }), - expect.objectContaining({ outcomeTokenId: 'token-3' }), - ]), - ); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-2' }), - ]), - ); - }); - - it('cleans up optimistic updates older than 1 minute', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - // Save the original Date.now - const realDateNow = Date.now.bind(global.Date); - const twoMinutesAgo = realDateNow() - 2 * 60 * 1000; - - // Mock Date.now to return 2 minutes ago for the first confirmClaim - const dateNowStub = jest.fn(); - global.Date.now = dateNowStub; - dateNowStub.mockReturnValueOnce(twoMinutesAgo); - - // Mark a position for removal 2 minutes ago (should be cleaned up) - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'old-position', - outcomeTokenId: 'token-old', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - // Now make Date.now return current time - dateNowStub.mockImplementation(realDateNow); - - // Add a new position for removal (this should trigger cleanup of old updates) - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'new-sold-position', - outcomeTokenId: 'token-new', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - // Mock fetch to return positions - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'old-position', - market: 'market-1', - size: '10', - value: '100', - }, - { - id: 'new-sold-position', - market: 'market-1', - size: '15', - value: '150', - }, - { - id: 'visible-position', - market: 'market-1', - size: '20', - value: '200', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'old-position', - outcomeTokenId: 'token-old', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'new-sold-position', - outcomeTokenId: 'token-new', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'visible-position', - outcomeTokenId: 'token-visible', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - old position should NOT be filtered (cleaned up by timeout), new-sold-position SHOULD be filtered - expect(result).toHaveLength(2); - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-old' }), - expect.objectContaining({ outcomeTokenId: 'token-visible' }), - ]), - ); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-new' }), - ]), - ); - - // Cleanup - global.Date.now = realDateNow; - }); - - it('tracks multiple optimistic removals for same address', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - // Mark 3 positions for removal - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - createMockPosition({ - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { id: 'position-1', market: 'market-1' }, - { id: 'position-2', market: 'market-1' }, - { id: 'position-3', market: 'market-1' }, - { id: 'position-4', market: 'market-1' }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-4', - outcomeTokenId: 'token-4', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - only position-4 should remain - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ outcomeTokenId: 'token-4' }); - }); - - it('handles multiple addresses independently', async () => { - // Arrange - const provider = createProvider(); - const addressA = '0x1111111111111111111111111111111111111111'; - const addressB = '0x2222222222222222222222222222222222222222'; - - // Mark position-1 (token-1) for removal for address A - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: { - address: addressA, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }, - }); - - // Mark position-2 (token-2) for removal for address B - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: { - address: addressB, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }, - }); - - // Mock fetch for address A - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { id: 'position-1', market: 'market-1' }, - { id: 'position-2', market: 'market-1' }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - get positions for address A - const resultA = await provider.getPositions({ address: addressA }); - - // Assert - only position-2 should be returned (position-1 filtered for addressA) - expect(resultA).toHaveLength(1); - expect(resultA[0]).toMatchObject({ outcomeTokenId: 'token-2' }); - }); - - it('returns all positions when no optimistic updates exist', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { id: 'position-1', market: 'market-1' }, - { id: 'position-2', market: 'market-1' }, - { id: 'position-3', market: 'market-1' }, - { id: 'position-4', market: 'market-1' }, - { id: 'position-5', market: 'market-1' }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-4', - outcomeTokenId: 'token-4', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-5', - outcomeTokenId: 'token-5', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - expect(result).toHaveLength(5); - }); - - it('handles empty optimistic updates list gracefully', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest - .fn() - .mockResolvedValue([{ id: 'position-1', market: 'market-1' }]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - no errors, returns all positions - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ outcomeTokenId: 'token-1' }); - }); - }); - - describe('placeOrder with optimistic updates', () => { - it('marks position for optimistic removal when selling', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.SELL, - outcomeTokenId: 'token-123', - positionId: 'position-123', - }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - await provider.placeOrder(orderParams); - - // Assert - subsequent getPositions should filter out the sold position - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest - .fn() - .mockResolvedValue([{ id: 'position-123', market: 'market-1' }]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-123', - outcomeTokenId: 'token-123', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - expect(positions).toHaveLength(0); - }); - - it('creates optimistic position when buying', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Mock getMarketDetails for optimistic position creation - mockGetMarketDetailsFromGammaApi.mockResolvedValue({ - id: 'market-1', - question: 'Test Market', - markets: [], - }); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokens: [{ id: 'token-456', title: 'Yes', price: 0.5 }], - }, - ], - }, - ]); - - // Mock submitClobOrder to return transaction amounts - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', // $1 USDC (6 decimals) - takingAmount: '2000000', // 2 shares - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder(orderParams); - - // Assert - getPositions should return API position OR optimistic position - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - // Should have the optimistic position - expect(positions.length).toBeGreaterThanOrEqual(1); - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-456', - ); - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - }); - - describe('optimistic position creation - BUY orders', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('creates optimistic position when buying new shares', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokenId: 'token-456', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - sharePrice: 0.5, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockSignPersonalMessage.mockResolvedValue('0xpersonalsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - expect(positions.length).toBeGreaterThanOrEqual(1); - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-456', - ); - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - - it('verifies createOptimisticPosition helper creates position with optimistic flag', () => { - // Arrange - const basePosition = createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - }); - - // Act - const optimisticPosition = createOptimisticPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - }); - - // Assert - expect(optimisticPosition.optimistic).toBe(true); - expect(optimisticPosition.id).toBe(basePosition.id); - expect(optimisticPosition.outcomeTokenId).toBe( - basePosition.outcomeTokenId, - ); - }); - - it('calculates initial values correctly for new position', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-789', - title: 'Yes', - tokenId: 'token-789', - price: 0.6, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-789', - outcomeId: 'outcome-789', - marketId: 'market-1', - sharePrice: 0.6, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '3000000', - takingAmount: '5000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-789', - ); - - expect(optimisticPos?.amount).toBe(5000000); - expect(optimisticPos?.initialValue).toBe(3000000); - expect(optimisticPos?.avgPrice).toBeCloseTo(0.6); - expect(optimisticPos?.size).toBe(5000000); - }); - - it('sets expected size for validation', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-999', - title: 'No', - tokenId: 'token-999', - price: 0.4, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-999', - outcomeId: 'outcome-999', - marketId: 'market-1', - sharePrice: 0.4, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '2000000', - takingAmount: '10000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-999', - ); - - expect(optimisticPos?.size).toBe(10000000); - }); - - it('fetches market details for complete position data', async () => { - // Arrange - const { provider, mockSigner } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-test', - outcomes: [ - { - id: 'outcome-test', - title: 'Maybe', - tokenId: 'token-test', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-test', - outcomeId: 'outcome-test', - marketId: 'market-test', - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ - marketId: 'market-test', - }); - }); - - it('handles market details fetch failure gracefully', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockGetMarketDetailsFromGammaApi.mockRejectedValue( - new Error('API error'), - ); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-error', - outcomeId: 'outcome-error', - marketId: 'market-error', - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - order still succeeds - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-error', - ); - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - - it('does not create optimistic update for claimable positions', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - ]), - }); - - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-claimable', - marketId: 'market-1', - claimable: true, - }), - ]); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-claimable', - outcomeId: 'outcome-claimable', - marketId: 'market-1', - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - - // Act - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Cannot place orders on claimable positions'); - }); - }); - - describe('optimistic position updates - UPDATE existing positions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('updates existing position when buying more shares', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-update', - title: 'Yes', - tokenId: 'token-update', - price: 0.5, - }, - ], - }); - - // First order - create initial position - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-update', - outcomeId: 'outcome-update', - marketId: 'market-1', - sharePrice: 0.5, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - // Second order - update existing position - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-update', - outcomeId: 'outcome-update', - marketId: 'market-1', - sharePrice: 0.6, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '3000000', - takingAmount: '5000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-update', - ); - - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - - it('accumulates amount and initialValue correctly', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-accum', - title: 'Yes', - tokenId: 'token-accum', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-accum', - outcomeId: 'outcome-accum', - marketId: 'market-1', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '2000000', - takingAmount: '4000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-accum', - outcomeId: 'outcome-accum', - marketId: 'market-1', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '3000000', - takingAmount: '6000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-accum', - ); - - // Second order creates a new optimistic position, not an update - // because optimistic positions don't persist in API - expect(optimisticPos?.amount).toBe(6000000); - expect(optimisticPos?.initialValue).toBe(3000000); - }); - - it('recalculates avgPrice after update', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-price', - title: 'Yes', - tokenId: 'token-price', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-price', - outcomeId: 'outcome-price', - marketId: 'market-1', - sharePrice: 0.5, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '5000000', - takingAmount: '10000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-price', - outcomeId: 'outcome-price', - marketId: 'market-1', - sharePrice: 0.7, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '7000000', - takingAmount: '10000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-price', - ); - - // avgPrice is based on the second order only since optimistic - // positions aren't returned from API for accumulation - expect(optimisticPos?.avgPrice).toBeCloseTo(0.7); - }); - - it('preserves existing position data', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-preserve', - outcomes: [ - { - id: 'outcome-preserve', - title: 'Maybe', - tokenId: 'token-preserve', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-preserve', - outcomeId: 'outcome-preserve', - marketId: 'market-preserve', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positionsAfterFirst = await provider.getPositions({ - address: mockSigner.address, - }); - - const firstPos = positionsAfterFirst.find( - (p) => p.outcomeTokenId === 'token-preserve', - ); - - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-preserve', - outcomeId: 'outcome-preserve', - marketId: 'market-preserve', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - const positionsAfterSecond = await provider.getPositions({ - address: mockSigner.address, - }); - - const updatedPos = positionsAfterSecond.find( - (p) => p.outcomeTokenId === 'token-preserve', - ); - - expect(updatedPos?.marketId).toBe(firstPos?.marketId); - expect(updatedPos?.outcomeId).toBe(firstPos?.outcomeId); - expect(updatedPos?.title).toBe(firstPos?.title); - }); - }); - - describe('integration tests - end-to-end flows', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('creates optimistic position on BUY then removes when API confirms', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-integration', - outcomes: [ - { - id: 'outcome-integration', - title: 'Yes', - tokenId: 'token-integration', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-integration', - outcomeId: 'outcome-integration', - marketId: 'market-integration', - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - Place order - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - Position is optimistic - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - let positions = await provider.getPositions({ - address: mockSigner.address, - }); - - let optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-integration', - ); - expect(optimisticPos?.optimistic).toBe(true); - - // Act - API now returns the confirmed position - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-123', - market: 'market-integration', - size: '2000000', - value: '100', - }, - ]), - }); - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-123', - outcomeTokenId: 'token-integration', - size: 2000000, - optimistic: false, - }), - ]); - - positions = await provider.getPositions({ - address: mockSigner.address, - }); - - // Assert - Optimistic update removed, API position returned - optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-integration', - ); - expect(optimisticPos?.optimistic).toBeFalsy(); - }); - - it('cleans up after timeout if API never confirms', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-timeout', - outcomes: [ - { - id: 'outcome-timeout', - title: 'Yes', - tokenId: 'token-timeout', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-timeout', - outcomeId: 'outcome-timeout', - marketId: 'market-timeout', - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - Place order - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - Position is optimistic - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - let positions = await provider.getPositions({ - address: mockSigner.address, - }); - - expect( - positions.find((p) => p.outcomeTokenId === 'token-timeout') - ?.optimistic, - ).toBe(true); - - // Act - Advance time by 2 minutes (past 1 minute timeout) - jest.advanceTimersByTime(2 * 60 * 1000); - - // Act - getPositions should clean up expired optimistic updates - positions = await provider.getPositions({ - address: mockSigner.address, - }); - - // Assert - Optimistic position should not be returned (expired) - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-timeout', - ); - expect(optimisticPos).toBeUndefined(); - }); - - it('handles BUY order followed by SELL order on same position', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-buysell', - outcomes: [ - { - id: 'outcome-buysell', - title: 'Yes', - tokenId: 'token-buysell', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - BUY order - const buyPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-buysell', - outcomeId: 'outcome-buysell', - marketId: 'market-buysell', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-buy', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ signer: mockSigner, preview: buyPreview }); - - // API returns the bought position - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-buysell', - market: 'market-buysell', - size: '2000000', - value: '100', - }, - ]), - }); - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-buysell', - outcomeTokenId: 'token-buysell', - size: 2000000, - }), - ]); - - let positions = await provider.getPositions({ - address: mockSigner.address, - }); - expect(positions).toHaveLength(1); - - // Act - SELL order - const sellPreview = createMockOrderPreview({ - side: Side.SELL, - outcomeTokenId: 'token-buysell', - positionId: 'position-buysell', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '2000000', - takingAmount: '1000000', - orderID: 'order-sell', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ signer: mockSigner, preview: sellPreview }); - - // Assert - Position should be marked for removal - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-buysell', - market: 'market-buysell', - size: '2000000', - value: '100', - }, - ]), - }); - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-buysell', - outcomeTokenId: 'token-buysell', - size: 2000000, - }), - ]); - - positions = await provider.getPositions({ - address: mockSigner.address, - }); - - expect(positions).toHaveLength(0); - }); - }); - }); - - describe('provider interface properties', () => { - it('exposes chainId property with value 137', () => { - const provider = createProvider(); - - expect(provider.chainId).toBe(137); - }); - - it('exposes name property with value Polymarket', () => { - const provider = createProvider(); - - expect(provider.name).toBe('Polymarket'); - }); - - it('exposes providerId property with value polymarket', () => { - const provider = createProvider(); - - expect(provider.providerId).toBe(POLYMARKET_PROVIDER_ID); - }); - }); - - describe('GameCache integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGameCacheInstance.overlayOnMarket.mockImplementation( - (market) => market, - ); - mockGameCacheInstance.overlayOnMarkets.mockImplementation( - (markets) => markets, - ); - }); - - describe('getMarkets', () => { - it('applies GameCache overlay to fetched markets when live sports are enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const mockEvents = [{ id: 'event-1' }, { id: 'event-2' }]; - const mockMarkets = [ - { id: 'market-1', title: 'Test Market 1' }, - { id: 'market-2', title: 'Test Market 2' }, - ]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue(mockMarkets); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - - await provider.getMarkets(); - - expect(mockGameCacheInstance.overlayOnMarkets).toHaveBeenCalledWith( - mockMarkets, - ); - }); - - it('returns markets with cached game data overlay applied when live sports are enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const mockEvents = [{ id: 'event-1' }]; - const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; - const overlaidMarkets = [ - { - id: 'market-1', - title: 'Test Market', - gameData: { score: '3-2', status: 'live' }, - }, - ]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue(mockMarkets); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockGameCacheInstance.overlayOnMarkets.mockReturnValue(overlaidMarkets); - - const result = await provider.getMarkets(); - - expect(result).toEqual(overlaidMarkets); - }); - - it('returns empty array when API fails without calling GameCache overlay', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockFetchEventsFromPolymarketApi.mockRejectedValue( - new Error('API error'), - ); - - const result = await provider.getMarkets(); - - expect(result).toEqual([]); - expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); - }); - }); - - describe('getCarouselMarkets', () => { - it('returns parsed markets from carousel API', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }, { id: 'event-2' }]; - const parsedMarkets = [ - { id: 'market-1', status: 'open', outcomes: [{ id: 'o1' }] }, - { id: 'market-2', status: 'open', outcomes: [{ id: 'o2' }] }, - ]; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: mockEvents[0] }, - { event: mockEvents[1] }, - ]); - mockParsePolymarketEvents.mockReturnValue(parsedMarkets); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual(parsedMarkets); - expect(mockFetchCarouselFromPolymarketApi).toHaveBeenCalled(); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ - category: 'trending', - sortMarketsBy: 'price', - }), - ); - }); - - it('returns empty array on error', async () => { - const provider = createProvider(); - - mockFetchCarouselFromPolymarketApi.mockRejectedValue( - new Error('carousel error'), - ); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([]); - }); - - it('filters out closed markets and markets with no outcomes', async () => { - const provider = createProvider(); - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { id: 'open-market', status: 'open', outcomes: [{ id: 'o1' }] }, - { id: 'closed-market', status: 'closed', outcomes: [{ id: 'o2' }] }, - { id: 'empty-outcomes', status: 'open', outcomes: [] }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([ - { id: 'open-market', status: 'open', outcomes: [{ id: 'o1' }] }, - ]); - }); - - it('excludes events with ended: true before parsing', async () => { - const provider = createProvider(); - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: { id: 'event-live', ended: false } }, - { event: { id: 'event-ended', ended: true } }, - { event: { id: 'event-scheduled' } }, - ]); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [{ id: 'event-live', ended: false }, { id: 'event-scheduled' }], - expect.any(Object), - ); - }); - - it('does not load teams for events with ended: true', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: { id: 'event-live', ended: false } }, - { event: { id: 'event-ended', ended: true } }, - ]); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockExtractNeededTeamsFromEvents).toHaveBeenCalledWith( - [{ id: 'event-live', ended: false }], - ['nfl'], - ); - }); - - it('loads teams when live sports is enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const mockEvents = [{ id: 'event-1' }]; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: mockEvents[0] }, - ]); - mockExtractNeededTeamsFromEvents.mockReturnValue( - new Map([['nfl', ['sea', 'den']]]), - ); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockExtractNeededTeamsFromEvents).toHaveBeenCalledWith( - mockEvents, - ['nfl'], - ); - expect(mockTeamsCacheInstance.ensureTeamsLoaded).toHaveBeenCalledWith( - 'nfl', - ['sea', 'den'], - ); - }); - - it('collapses outcomes to the moneyline outcome when present', async () => { - const provider = createProvider(); - const moneylineOutcome = { - id: 'match-winner', - sportsMarketType: 'moneyline', - tokens: [{ title: 'Spirit' }, { title: 'MOUZ' }], - }; - const overUnderOutcome = { - id: 'ou-2.5', - sportsMarketType: 'totals', - tokens: [{ title: 'Over' }, { title: 'Under' }], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'cs-spirit-vs-mouz', - status: 'open', - outcomes: [overUnderOutcome, moneylineOutcome], - }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([ - { - id: 'cs-spirit-vs-mouz', - status: 'open', - outcomes: [moneylineOutcome], - }, - ]); - }); - - it('matches moneyline regardless of sportsMarketType casing', async () => { - const provider = createProvider(); - const moneylineOutcome = { - id: 'match-winner', - sportsMarketType: 'MoneyLine', - tokens: [{ title: 'Home' }, { title: 'Away' }], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'm1', - status: 'open', - outcomes: [ - { id: 'spread', sportsMarketType: 'spreads' }, - moneylineOutcome, - ], - }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result[0].outcomes).toEqual([moneylineOutcome]); - }); - - it('passes markets through unchanged when no moneyline outcome exists', async () => { - const provider = createProvider(); - const marketWithoutMoneyline = { - id: 'binary-market', - status: 'open', - outcomes: [ - { id: 'yes', tokens: [{ title: 'Yes' }, { title: 'No' }] }, - ], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([marketWithoutMoneyline]); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([marketWithoutMoneyline]); - }); - - it('preserves all home/draw/away tokens on the moneyline outcome for soccer markets', async () => { - const provider = createProvider(); - const soccerMoneyline = { - id: 'match-winner', - sportsMarketType: 'moneyline', - tokens: [ - { id: 'tot', title: 'Tottenham' }, - { id: 'draw', title: 'Draw' }, - { id: 'bri', title: 'Brighton' }, - ], - }; - const totalGoals = { - id: 'total-goals', - sportsMarketType: 'totals', - tokens: [{ title: 'Over' }, { title: 'Under' }], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'tot-vs-bri', - status: 'open', - game: { - homeTeam: { name: 'Tottenham' }, - awayTeam: { name: 'Brighton' }, - }, - outcomes: [soccerMoneyline, totalGoals], - }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result[0].outcomes).toEqual([soccerMoneyline]); - expect(result[0].outcomes[0].tokens).toHaveLength(3); - expect( - result[0].outcomes[0].tokens.map((t: { title: string }) => t.title), - ).toEqual(['Tottenham', 'Draw', 'Brighton']); - }); - }); - - describe('getMarketDetails', () => { - it('applies GameCache overlay to fetched market details when event is a sports event', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValue('nfl'); - const mockEvent = { - id: 'market-1', - slug: 'sea-vs-den-2024-01-15', - question: 'Test Market?', - }; - const parsedMarket = { - id: 'market-1', - title: 'Test Market', - providerId: POLYMARKET_PROVIDER_ID, - }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockExtractNeededTeamsFromEvents.mockReturnValue( - new Map([['nfl', ['sea', 'den']]]), - ); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).toHaveBeenCalledWith( - 'nfl', - ['sea', 'den'], - ); - expect(mockGameCacheInstance.overlayOnMarket).toHaveBeenCalledWith( - parsedMarket, - ); - }); - - it('returns market with cached game data overlay applied when event is a sports event', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValue('nfl'); - const mockEvent = { - id: 'market-1', - slug: 'sea-vs-den-2024-01-15', - question: 'Test Market?', - }; - const parsedMarket = { id: 'market-1', title: 'Test Market' }; - const overlaidMarket = { - id: 'market-1', - title: 'Test Market', - gameData: { score: '1-0', status: 'live', elapsed: '45:00' }, - }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockExtractNeededTeamsFromEvents.mockReturnValue( - new Map([['nfl', ['sea', 'den']]]), - ); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - mockGameCacheInstance.overlayOnMarket.mockReturnValue(overlaidMarket); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(result).toEqual(overlaidMarket); - expect(mockTeamsCacheInstance.ensureTeamsLoaded).toHaveBeenCalledWith( - 'nfl', - ['sea', 'den'], - ); - }); - - it('skips GameCache overlay when event is not a sports event despite leagues being enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValue(null); - const mockEvent = { id: 'market-1', question: 'Will BTC hit 100k?' }; - const parsedMarket = { id: 'market-1', title: 'Will BTC hit 100k?' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - expect(result).toEqual(parsedMarket); - }); - - it('throws error when parsing fails without calling GameCache overlay', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValueOnce('nfl'); - mockGetMarketDetailsFromGammaApi.mockResolvedValue({}); - mockParsePolymarketEvents.mockReturnValue([]); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow('Failed to parse market details'); - expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); - }); - }); - }); - - describe('WebSocket methods', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('subscribeToGameUpdates', () => { - it('delegates to WebSocketManager.subscribeToGame', () => { - const provider = createProvider(); - const mockCallback = jest.fn(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToGameUpdates( - 'game-123', - mockCallback, - ); - - expect( - mockWebSocketManagerInstance.subscribeToGame, - ).toHaveBeenCalledWith('game-123', mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); - }); - - it('returns unsubscribe function from WebSocketManager', () => { - const provider = createProvider(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToGameUpdates( - 'game-456', - jest.fn(), - ); - - unsubscribe(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - }); - - describe('subscribeToMarketPrices', () => { - it('delegates to WebSocketManager.subscribeToMarketPrices', () => { - const provider = createProvider(); - const mockCallback = jest.fn(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToMarketPrices( - ['token-1', 'token-2'], - mockCallback, - ); - - expect( - mockWebSocketManagerInstance.subscribeToMarketPrices, - ).toHaveBeenCalledWith(['token-1', 'token-2'], mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); - }); - - it('returns unsubscribe function from WebSocketManager', () => { - const provider = createProvider(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToMarketPrices( - ['token-1'], - jest.fn(), - ); - - unsubscribe(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - }); - - describe('subscribeToCryptoPrices', () => { - it('delegates to WebSocketManager.subscribeToCryptoPrices', () => { - const provider = createProvider(); - const mockCallback = jest.fn(); - const mockUnsubscribeCrypto = jest.fn(); - mockWebSocketManagerInstance.subscribeToCryptoPrices.mockReturnValue( - mockUnsubscribeCrypto, - ); - - const unsubscribe = provider.subscribeToCryptoPrices( - ['btcusdt', 'ethusdt'], - mockCallback, - ); - - expect( - mockWebSocketManagerInstance.subscribeToCryptoPrices, - ).toHaveBeenCalledWith(['btcusdt', 'ethusdt'], mockCallback); - expect(unsubscribe).toBe(mockUnsubscribeCrypto); - }); - - it('returns unsubscribe function from WebSocketManager', () => { - const provider = createProvider(); - const mockUnsubscribeCrypto = jest.fn(); - mockWebSocketManagerInstance.subscribeToCryptoPrices.mockReturnValue( - mockUnsubscribeCrypto, - ); - - const unsubscribe = provider.subscribeToCryptoPrices( - ['btcusdt'], - jest.fn(), - ); - - unsubscribe(); - - expect(mockUnsubscribeCrypto).toHaveBeenCalled(); - }); - }); - - describe('getConnectionStatus', () => { - it('returns connection status from WebSocketManager', () => { - const provider = createProvider(); - mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ - sportsConnected: true, - marketConnected: false, - rtdsConnected: false, - gameSubscriptionCount: 5, - priceSubscriptionCount: 10, - cryptoPriceSubscriptionCount: 0, - }); - - const status = provider.getConnectionStatus(); - - expect(status).toEqual({ - sportsConnected: true, - marketConnected: false, - rtdsConnected: false, - }); - }); - - it('maps WebSocketManager status to ConnectionStatus interface', () => { - const provider = createProvider(); - mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ - sportsConnected: false, - marketConnected: true, - rtdsConnected: true, - gameSubscriptionCount: 0, - priceSubscriptionCount: 3, - cryptoPriceSubscriptionCount: 1, - }); - - const status = provider.getConnectionStatus(); - - expect(status.sportsConnected).toBe(false); - expect(status.marketConnected).toBe(true); - expect(Object.keys(status)).toEqual([ - 'sportsConnected', - 'marketConnected', - 'rtdsConnected', - ]); - }); - }); - }); - - describe('Live sports disabled', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getMarkets', () => { - it('skips TeamsCache loading when live sports leagues are empty', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [], - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - }); - - it('skips GameCache overlay when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }]; - const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue(mockMarkets); - - const result = await provider.getMarkets(); - - expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); - expect(result).toEqual(mockMarkets); - }); - - it('does not pass teamLookup when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'sports', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets({ category: 'sports' }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ teamLookup: undefined }), - ); - }); - - it('skips TeamsCache loading when live sports config is defaulted', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [], - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - }); - }); - - describe('getMarketDetails', () => { - it('skips TeamsCache loading when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvent = { id: 'market-1', question: 'Test?' }; - const parsedMarket = { id: 'market-1', title: 'Test' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - }); - - it('skips GameCache overlay when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvent = { id: 'market-1', question: 'Test?' }; - const parsedMarket = { id: 'market-1', title: 'Test' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); - expect(result).toEqual(parsedMarket); - }); - - it('does not pass teamLookup when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvent = { id: 'market-1', question: 'Test?' }; - const parsedMarket = { id: 'market-1', title: 'Test' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mockEvent], - expect.objectContaining({ teamLookup: undefined }), - ); - }); - }); - }); - - describe('getMarketSeries', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('calls the series events endpoint with the requested params', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }]; - const parsedMarkets = [{ id: 'market-1' }]; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockEvents), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - mockParsePolymarketEvents.mockReturnValue(parsedMarkets); - - await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - limit: 10, - }); - - const requestUrl = new URL((global.fetch as jest.Mock).mock.calls[0][0]); - - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('series_id=10684'), - ); - expect(requestUrl.origin + requestUrl.pathname).toBe( - 'https://gamma-api.polymarket.com/events', - ); - expect(requestUrl.searchParams.get('series_id')).toBe('10684'); - expect(requestUrl.searchParams.get('end_date_min')).toBe( - '2026-04-06T00:00:00.000Z', - ); - expect(requestUrl.searchParams.get('end_date_max')).toBe( - '2026-04-07T00:00:00.000Z', - ); - expect(requestUrl.searchParams.get('limit')).toBe('10'); - expect(requestUrl.searchParams.get('order')).toBe('endDate'); - expect(requestUrl.searchParams.get('ascending')).toBe('true'); - }); - - it('returns an empty array when the API returns no events', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue([]), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - }); - - expect(result).toEqual([]); - expect(mockParsePolymarketEvents).not.toHaveBeenCalled(); - }); - - it('uses the default limit when one is not provided', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue([{ id: 'event-1' }]), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - }); - - const requestUrl = new URL((global.fetch as jest.Mock).mock.calls[0][0]); - - expect(requestUrl.searchParams.get('limit')).toBe('50'); - }); - }); - - describe('extendedSportsMarketsLeagues pass-through', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('getMarkets passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl', 'nba']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [{ id: 'event-1' }], - category: 'trending', - isSearch: false, - }); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); - - it('getMarkets passes empty extendedSportsMarketsLeagues when flag has no leagues', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [{ id: 'event-1' }], - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: [] }), - ); - }); - - it('getMarketDetails passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - const mockEvent = { id: 'market-1', question: 'Test?' }; - mockGetEventLeague.mockReturnValue(null); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([ - { id: 'market-1', title: 'Test' }, - ]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); - - it('getMarketSeries passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl', 'nba']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue([{ id: 'event-1' }]), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); - - it('getCarouselMarkets passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: { id: 'event-1' } }, - ]); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); + expect(result).toEqual({ callData: '0xsignedWithdraw', amount: 1 }); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 35f5890083b..92bad4e401b 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -60,26 +60,20 @@ import { SignWithdrawResponse, } from '../types'; import { - MIN_COLLATERAL_BALANCE_FOR_CLAIM, + COLLATERAL_TOKEN_DECIMALS, ORDER_RATE_LIMIT_MS, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, SAFE_EXEC_GAS_LIMIT, } from './constants'; -import { PERMIT2_ADDRESS } from './safe/constants'; import { computeProxyAddress, createPermit2FeeAuthorization, - createSafeFeeAuthorization, - getClaimTransaction, getDeployProxyWalletTransaction, - getProxyWalletAllowancesTransaction, - getSafeUsdcAmount, - getSafeUsdcAmountRaw, - getWithdrawTransactionCallData, - hasAllowances, + getSafeTransferAmount, + getSafeTransferAmountRaw, } from './safe/utils'; -import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; +import { Permit2FeeAuthorization } from './safe/types'; import { ApiKeyCreds, OrderType, @@ -94,18 +88,16 @@ import { fetchEventsFromPolymarketApi, fetchCarouselFromPolymarketApi, getBalance, - getContractConfig, getL2Headers, fetchChildEventsFromGammaApi, getMarketDetailsFromGammaApi, - mergeChildEventsIntoParent, - getOrderTypedData, getPolymarketEndpoints, + getRawBalance, + mergeChildEventsIntoParent, parsePolymarketActivity, parsePolymarketEvents, parsePolymarketPositions, previewOrder, - submitClobOrder, } from './utils'; import { PredictFeatureFlags } from '../../types/flags'; import { @@ -119,7 +111,7 @@ import { WebSocketManager } from './WebSocketManager'; import { getProtocolDepositTokenAddress, getProtocolWithdrawTokenAddress, - resolvePolymarketProtocol, + POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from './protocol/definitions'; import { @@ -168,6 +160,7 @@ export class PolymarketProvider implements PredictProvider { #apiKeysByProtocolAddress: Map = new Map(); #accountStateByAddress: Map = new Map(); + #safeAddressesWithZeroLegacyUsdceBalance = new Set(); #lastBuyOrderTimestampByAddress: Map = new Map(); #buyOrderInProgressByAddress: Map = new Map(); #optimisticPositionUpdatesByAddress = new Map< @@ -324,7 +317,36 @@ export class PolymarketProvider implements PredictProvider { } #getProtocol(): PolymarketProtocolDefinition { - return resolvePolymarketProtocol(this.#getFeatureFlags()); + return POLYMARKET_V2_PROTOCOL; + } + + #getLegacyUsdceBalanceCacheKey(safeAddress: string): string { + return getAddress(safeAddress).toLowerCase(); + } + + async #getLegacyUsdceBalance({ + safeAddress, + protocol, + }: { + safeAddress: string; + protocol: PolymarketProtocolDefinition; + }): Promise { + const cacheKey = this.#getLegacyUsdceBalanceCacheKey(safeAddress); + + if (this.#safeAddressesWithZeroLegacyUsdceBalance.has(cacheKey)) { + return 0n; + } + + const balance = await getRawBalance({ + address: safeAddress, + tokenAddress: protocol.collateral.legacyUsdceToken, + }); + + if (balance === 0n) { + this.#safeAddressesWithZeroLegacyUsdceBalance.add(cacheKey); + } + + return balance; } #pickExecutor(executors: string[]): string { @@ -391,185 +413,14 @@ export class PolymarketProvider implements PredictProvider { throw new Error(error ?? PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); } - async #submitOrderV1({ + async #submitOrder({ signer, preview, protocol, }: { signer: Signer; preview: OrderPreview; - protocol: Extract; - }) { - const chainId = POLYGON_MAINNET_CHAIN_ID; - const makerAddress = - this.#accountStateByAddress.get(signer.address)?.address ?? - computeProxyAddress(signer.address); - - if (!makerAddress) { - throw new Error('Maker address not found'); - } - - const order = buildProtocolUnsignedOrder({ - protocol, - preview, - makerAddress, - signerAddress: signer.address, - }); - - const typedData = getOrderTypedData({ - order, - chainId, - verifyingContract: - getContractConfig(chainId)[ - preview.negRisk ? 'negRiskExchange' : 'exchange' - ], - }); - - const signature = await signer.signTypedMessage( - { data: typedData, from: signer.address }, - SignTypedDataVersion.V4, - ); - - const signedOrder = { - ...order, - signature, - }; - const signerApiKey = await this.getApiKey({ - address: signer.address, - protocol, - }); - const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); - const shouldUsePermit2 = this.#hasPermit2Config({ - permit2Enabled: preview.fees?.permit2Enabled, - executors: preview.fees?.executors, - }); - - let feeAuthorization: - | SafeFeeAuthorization - | Permit2FeeAuthorization - | undefined; - let executor: string | undefined; - let permit2FeeReady = false; - - if (preview.fees !== undefined && preview.fees.totalFee > 0) { - const safeAddress = computeProxyAddress(signer.address); - const feeAmountInUsdc = BigInt( - parseUnits(preview.fees.totalFee.toString(), 6).toString(), - ); - - if (shouldUsePermit2) { - permit2FeeReady = true; - executor = this.#pickExecutor(preview.fees.executors ?? []); - feeAuthorization = await createPermit2FeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - spender: executor, - }); - } else { - feeAuthorization = await createSafeFeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - to: preview.fees.collector, - }); - } - } - - let allowancesTx: { to: string; data: string } | undefined; - let permit2AllowanceReady = false; - const hasSafeFeeAuth = feeAuthorization !== undefined && !permit2FeeReady; - - if (feeCollection.permit2Enabled && !hasSafeFeeAuth) { - try { - const accountState = await this.getAccountState({ - ownerAddress: signer.address, - }); - - if (accountState.hasAllowances) { - permit2AllowanceReady = true; - } else { - const allowanceTx = await getProxyWalletAllowancesTransaction({ - signer, - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - - allowancesTx = allowanceTx.params; - permit2AllowanceReady = true; - } - } catch (allowanceError) { - DevLogger.log( - 'PolymarketProvider: Failed to generate allowances transaction', - { error: allowanceError }, - ); - Logger.error( - allowanceError instanceof Error - ? allowanceError - : new Error(String(allowanceError)), - this.getErrorContext('placeOrder:allowancesTx', { - operation: 'generate_allowances_tx', - }), - ); - } - } - - const orderType = this.#getPlaceOrderType({ - preview, - feeCollection, - fakOrdersEnabled, - permit2FeeReady, - permit2AllowanceReady, - }); - - const clobOrder = serializeProtocolRelayerOrder({ - signedOrder, - owner: signerApiKey.apiKey, - orderType, - side: preview.side, - }); - const body = JSON.stringify(clobOrder); - const headers = await getL2Headers({ - l2HeaderArgs: { - method: 'POST', - requestPath: `/order`, - body, - }, - address: clobOrder.order.signer ?? '', - apiKey: signerApiKey, - }); - - const orderResult = await submitClobOrder({ - headers, - clobOrder, - feeAuthorization, - executor, - allowancesTx, - }); - - if (!orderResult.success) { - DevLogger.log('PolymarketProvider: Place order failed', { - error: orderResult.error, - errorDetails: undefined, - side: preview.side, - outcomeTokenId: preview.outcomeTokenId, - }); - this.#throwPlaceOrderError({ - error: orderResult.error, - side: preview.side, - }); - } - - return orderResult.response; - } - - async #submitOrderV2({ - signer, - preview, - protocol, - }: { - signer: Signer; - preview: OrderPreview; - protocol: Extract; + protocol: PolymarketProtocolDefinition; }) { const safeAddress = this.#accountStateByAddress.get(signer.address)?.address ?? @@ -579,7 +430,7 @@ export class PolymarketProvider implements PredictProvider { protocol, preview: { ...preview, - feeRateBps: getPreviewFeeRateBpsForProtocol({ protocol, preview }), + feeRateBps: getPreviewFeeRateBpsForProtocol(), }, makerAddress: safeAddress, signerAddress: getAddress(signer.address), @@ -605,7 +456,6 @@ export class PolymarketProvider implements PredictProvider { }; const signerApiKey = await this.getApiKey({ address: signer.address, - protocol, }); const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); const shouldUsePermit2 = this.#hasPermit2Config({ @@ -640,10 +490,15 @@ export class PolymarketProvider implements PredictProvider { let permit2AllowanceReady = false; try { + const safeLegacyUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress, + protocol, + }); allowancesTx = await buildTradeAllowancesTx({ signer, safeAddress, protocol, + safeUsdceBalance: safeLegacyUsdceBalance, }); permit2AllowanceReady = true; } catch (allowanceError) { @@ -697,7 +552,7 @@ export class PolymarketProvider implements PredictProvider { }); if (!orderResult.success) { - DevLogger.log('PolymarketProvider: Place order V2 failed', { + DevLogger.log('PolymarketProvider: Place order failed', { error: orderResult.error, errorDetails: undefined, side: preview.side, @@ -824,12 +679,10 @@ export class PolymarketProvider implements PredictProvider { private async getApiKey({ address, - protocol, }: { address: string; - protocol: Pick; }): Promise { - const cacheKey = `${protocol.key}:${protocol.transport.clobBaseUrl}:${address}`; + const cacheKey = address; const cachedApiKey = this.#apiKeysByProtocolAddress.get(cacheKey); if (cachedApiKey) { return cachedApiKey; @@ -837,9 +690,6 @@ export class PolymarketProvider implements PredictProvider { const apiKeyCreds = await createApiKey({ address, - clobVersion: protocol.key, - clobBaseUrl: - protocol.key === 'v2' ? protocol.transport.clobBaseUrl : undefined, }); this.#apiKeysByProtocolAddress.set(cacheKey, apiKeyCreds); return apiKeyCreds; @@ -1734,20 +1584,13 @@ export class PolymarketProvider implements PredictProvider { }, ): Promise { const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); - const protocol = this.#getProtocol(); const basePreview = await previewOrder({ ...params, feeCollection, - isV2: protocol.key === 'v2', - clobBaseUrl: - protocol.key === 'v2' ? protocol.transport.clobBaseUrl : undefined, }); const normalizedPreview = { ...basePreview, - feeRateBps: getPreviewFeeRateBpsForProtocol({ - protocol, - preview: basePreview, - }), + feeRateBps: getPreviewFeeRateBpsForProtocol(), }; let orderType = OrderType.FOK; @@ -1829,18 +1672,11 @@ export class PolymarketProvider implements PredictProvider { try { const protocol = this.#getProtocol(); - const orderResponse = - protocol.key === 'v2' - ? await this.#submitOrderV2({ - signer, - preview, - protocol, - }) - : await this.#submitOrderV1({ - signer, - preview, - protocol, - }); + const orderResponse = await this.#submitOrder({ + signer, + preview, + protocol, + }); if (side === Side.BUY) { this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now()); @@ -1952,48 +1788,21 @@ export class PolymarketProvider implements PredictProvider { throw new Error('Safe address not found for claim'); } - if (protocol.key === 'v2') { - const claimTransaction = await buildClaimTransaction({ - signer, - positions, - safeAddress, - protocol, - }); - - return { - chainId: POLYGON_MAINNET_CHAIN_ID, - transactions: [claimTransaction], - }; - } - - const signerBalance = await getBalance({ address: signer.address }); - const includeTransferTransaction = - signerBalance < MIN_COLLATERAL_BALANCE_FOR_CLAIM; - - // Generate claim transaction - let claimTransaction; - try { - claimTransaction = await getClaimTransaction({ - signer, - positions, - safeAddress, - includeTransferTransaction, - }); - } catch (error) { - throw new Error( - `Failed to generate claim transaction: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ); - } - - if (!claimTransaction || claimTransaction.length === 0) { - throw new Error('No claim transaction generated'); - } + const safeLegacyUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress, + protocol, + }); + const claimTransaction = await buildClaimTransaction({ + signer, + positions, + safeAddress, + protocol, + safeLegacyUsdceBalance, + }); return { chainId: POLYGON_MAINNET_CHAIN_ID, - transactions: claimTransaction, + transactions: [claimTransaction], }; } catch (error) { // Log error for debugging @@ -2137,51 +1946,23 @@ export class PolymarketProvider implements PredictProvider { type: TransactionType.predictDeposit, }; - if (protocol.key === 'v2') { - transactions.push(depositTransaction); - - const maintenanceTransaction = await buildDepositMaintenanceTransaction({ - signer, - safeAddress: accountState.address, - protocol, - }); - - if (maintenanceTransaction) { - transactions.push(maintenanceTransaction); - } - - return { - chainId: CHAIN_IDS.POLYGON, - transactions, - }; - } - - if (!accountState.hasAllowances) { - const { feeCollection: depositFeeCollection } = this.#getFeatureFlags(); - const extraUsdcSpenders = depositFeeCollection.permit2Enabled - ? [PERMIT2_ADDRESS] - : []; - const allowanceTransaction = await getProxyWalletAllowancesTransaction({ - signer, - extraUsdcSpenders, - }); - - if (!allowanceTransaction) { - throw new Error('Failed to get proxy wallet allowances transaction'); - } + transactions.push(depositTransaction); - if ( - !allowanceTransaction.params?.to || - !allowanceTransaction.params?.data - ) { - throw new Error('Invalid allowance transaction: missing params'); - } + const preExistingSafeUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress: accountState.address, + protocol, + }); + const maintenanceTransaction = await buildDepositMaintenanceTransaction({ + signer, + safeAddress: accountState.address, + protocol, + preExistingSafeUsdceBalance, + }); - transactions.push(allowanceTransaction); + if (maintenanceTransaction) { + transactions.push(maintenanceTransaction); } - transactions.push(depositTransaction); - return { chainId: CHAIN_IDS.POLYGON, transactions, @@ -2215,21 +1996,12 @@ export class PolymarketProvider implements PredictProvider { throw new Error('Failed to get safe address'); } - // Check deployment status and allowances let isDeployed: boolean; - let hasAllowancesResult: boolean; - const { feeCollection: flagFeeCollection } = this.#getFeatureFlags(); - const extraUsdcSpenders = flagFeeCollection.permit2Enabled - ? [PERMIT2_ADDRESS] - : []; try { - [isDeployed, hasAllowancesResult] = await Promise.all([ - isSmartContractAddress( - address, - numberToHex(POLYGON_MAINNET_CHAIN_ID), - ), - hasAllowances({ address, extraUsdcSpenders }), - ]); + isDeployed = await isSmartContractAddress( + address, + numberToHex(POLYGON_MAINNET_CHAIN_ID), + ); } catch (error) { throw new Error( `Failed to check account state: ${ @@ -2241,7 +2013,6 @@ export class PolymarketProvider implements PredictProvider { const accountState = { address: address as `0x${string}`, isDeployed, - hasAllowances: hasAllowancesResult, }; this.#accountStateByAddress.set(ownerAddress, accountState); @@ -2267,20 +2038,20 @@ export class PolymarketProvider implements PredictProvider { computeProxyAddress(address); const protocol = this.#getProtocol(); - if (protocol.key !== 'v2') { - return await getBalance({ address: predictAddress }); - } + const [pusdBalance, legacyUsdceBalance] = await Promise.all([ + getBalance({ + address: predictAddress, + tokenAddress: protocol.collateral.tradingToken, + }), + this.#getLegacyUsdceBalance({ + safeAddress: predictAddress, + protocol, + }), + ]); - const balances = await Promise.all( - protocol.collateral.balanceTokens.map((tokenAddress) => - getBalance({ - address: predictAddress, - tokenAddress, - }), - ), + return ( + pusdBalance + Number(legacyUsdceBalance) / 10 ** COLLATERAL_TOKEN_DECIMALS ); - - return balances.reduce((sum, balance) => sum + balance, 0); } public async prepareWithdraw( @@ -2332,32 +2103,23 @@ export class PolymarketProvider implements PredictProvider { this.#accountStateByAddress.get(signer.address)?.address ?? computeProxyAddress(signer.address); - const amount = getSafeUsdcAmount(callData); - const requestedAmountRaw = getSafeUsdcAmountRaw(callData); + const amount = getSafeTransferAmount(callData); + const requestedAmountRaw = getSafeTransferAmountRaw(callData); - if (protocol.key === 'v2') { - const signedWithdrawTransaction = await buildWithdrawTransaction({ - signer, - safeAddress, - requestedAmountRaw, - mode: protocol.workflow.withdrawMode, - protocol, - }); - - return { - callData: signedWithdrawTransaction.params.data, - amount, - }; - } - - const signedCallData = await getWithdrawTransactionCallData({ - data: callData, + const safeLegacyUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress, + protocol, + }); + const signedWithdrawTransaction = await buildWithdrawTransaction({ signer, safeAddress, + requestedAmountRaw, + protocol, + safeLegacyUsdceBalance, }); return { - callData: signedCallData, + callData: signedWithdrawTransaction.params.data, amount, }; } diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 01fe31fb2ae..56d34f5e0f6 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -5,19 +5,18 @@ export const POLYMARKET_PROVIDER_ID = 'polymarket'; export const POLYMARKET_TERMS_URL = 'https://polymarket.com/tos'; export const DEFAULT_CLOB_BASE_URL = 'https://clob.polymarket.com'; -export const LEGACY_V2_CLOB_BASE_URL = 'https://clob-v2.polymarket.com'; /** * Default slippage for market orders. */ export const SLIPPAGE_BUY = 0.03; // 3% export const SLIPPAGE_SELL = 0.05; // 5% -// BUY is floored at maxAmountSpent + tickSize. SELL has no floor — user accepts up to 99% less USDC. +// BUY is floored at maxAmountSpent + tickSize. SELL has no floor — user accepts up to 99% less pUSD. export const SLIPPAGE_BEST_AVAILABLE = 0.99; // 99% export const ORDER_RATE_LIMIT_MS = 5000; -export const MIN_COLLATERAL_BALANCE_FOR_CLAIM = 0.5; +export const MIN_PUSD_BALANCE_FOR_CLAIM_GAS = 0.5; export const POLYGON_MAINNET_CHAIN_ID = 137; export const POLYGON_MAINNET_CAIP_CHAIN_ID = @@ -76,14 +75,6 @@ export const ROUNDING_CONFIG: Record = { */ export const SAFE_EXEC_GAS_LIMIT = 121000; -export const MATIC_CONTRACTS: ContractConfig = { - exchange: '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E', - negRiskAdapter: '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', - negRiskExchange: '0xC5d563A36AE78145C45a50134d48A1215220f80a', - collateral: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', -}; - export const MATIC_CONTRACTS_V2: ContractConfig = { exchange: '0xE111180000d2663C0091e4f400237545B87B996B', negRiskAdapter: '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', @@ -92,22 +83,19 @@ export const MATIC_CONTRACTS_V2: ContractConfig = { conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', }; -export const USDC_E_ADDRESS = MATIC_CONTRACTS.collateral; +export const USDC_E_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; export const COLLATERAL_ONRAMP_ADDRESS = '0x93070a847efEf7F70739046A929D47a521F5B8ee'; -export const COLLATERAL_OFFRAMP_ADDRESS = - '0x2957922Eb93258b93368531d39fAcCA3B4dC5854'; - export const CTF_COLLATERAL_ADAPTER_ADDRESS = '0xAdA100Db00Ca00073811820692005400218FcE1f'; export const NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS = '0xadA2005600Dec949baf300f4C6120000bDB6eAab'; -export const POLYGON_USDC_CAIP_ASSET_ID = - `${POLYGON_MAINNET_CAIP_CHAIN_ID}/erc20:${MATIC_CONTRACTS.collateral}` as const; +export const POLYGON_PUSD_CAIP_ASSET_ID = + `${POLYGON_MAINNET_CAIP_CHAIN_ID}/erc20:${MATIC_CONTRACTS_V2.collateral}` as const; export const SPORTS_MARKET_TYPE_TO_GROUP: Record = { first_half_moneyline: 'first_half', diff --git a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts index 3061f11eb26..01eb962364e 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts @@ -4,34 +4,31 @@ import type { PredictPosition } from '../../../types'; import type { Signer } from '../../types'; import { HASH_ZERO_BYTES32, - MIN_COLLATERAL_BALANCE_FOR_CLAIM, + MIN_PUSD_BALANCE_FOR_CLAIM_GAS, } from '../constants'; import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from '../protocol/definitions'; import { OperationType, type SafeTransaction } from '../safe/types'; -import { encodeRedeemPositions } from '../utils'; +import { encodeErc20Transfer, encodeRedeemPositions } from '../utils'; import { buildSignedSafeExecution, - buildUnwrapTransaction, compileAllowanceMaintenanceTransactions, getRawTokenBalance, } from './core'; import { inspectMissingRequirements } from './inspectMissingRequirements'; import { - getCanonicalV2AllowanceRequirements, + getActiveV2AllowanceRequirements, + getLegacySweepAllowanceRequirements, type V2AllowanceRequirement, } from './v2AllowanceRequirements'; -const MIN_GAS_STATION_USDCE_BALANCE_RAW = BigInt( - parseUnits(MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), 6).toString(), +const MIN_PUSD_BALANCE_FOR_CLAIM_GAS_RAW = BigInt( + parseUnits(MIN_PUSD_BALANCE_FOR_CLAIM_GAS.toString(), 6).toString(), ); -type PolymarketV2ProtocolDefinition = Extract< - PolymarketProtocolDefinition, - { key: 'v2' } ->; +type PolymarketV2ProtocolDefinition = PolymarketProtocolDefinition; function buildClaimSubtransactions({ positions, @@ -58,9 +55,11 @@ function buildClaimSubtransactions({ export function getClaimRequirements({ positions, protocol = POLYMARKET_V2_PROTOCOL, + includeLegacySweep = true, }: { positions: PredictPosition[]; protocol?: PolymarketV2ProtocolDefinition; + includeLegacySweep?: boolean; }): V2AllowanceRequirement[] { const requiresStandardAdapter = positions.some( (position) => !position.negRisk, @@ -68,7 +67,10 @@ export function getClaimRequirements({ const requiresNegRiskAdapter = positions.some((position) => position.negRisk); return [ - ...getCanonicalV2AllowanceRequirements(protocol), + ...(includeLegacySweep + ? getLegacySweepAllowanceRequirements(protocol) + : []), + ...getActiveV2AllowanceRequirements(protocol), ...(requiresStandardAdapter ? [ { @@ -91,9 +93,9 @@ export function getClaimRequirements({ } export interface ClaimPlan { - gasStationDeficit: bigint; - safeUsdceBalance: bigint; - eoaUsdceBalance: bigint; + gasTokenDeficit: bigint; + safeLegacyUsdceBalance: bigint; + eoaPusdBalance: bigint; missingRequirements: V2AllowanceRequirement[]; transactions: SafeTransaction[]; } @@ -103,32 +105,40 @@ export async function planClaim({ positions, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance: providedSafeLegacyUsdceBalance, }: { signer: Signer; positions: PredictPosition[]; safeAddress: string; protocol?: PolymarketV2ProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }): Promise { - const [missingRequirements, safeUsdceBalance, eoaUsdceBalance] = - await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getClaimRequirements({ positions, protocol }), - }), - getRawTokenBalance({ - address: safeAddress, - tokenAddress: protocol.collateral.legacyUsdceToken, - }), - getRawTokenBalance({ - address: signer.address, - tokenAddress: protocol.collateral.legacyUsdceToken, + const safeLegacyUsdceBalance = + providedSafeLegacyUsdceBalance ?? + (await getRawTokenBalance({ + address: safeAddress, + tokenAddress: protocol.collateral.legacyUsdceToken, + })); + + const [missingRequirements, eoaPusdBalance] = await Promise.all([ + inspectMissingRequirements({ + address: safeAddress, + requirements: getClaimRequirements({ + positions, + protocol, + includeLegacySweep: safeLegacyUsdceBalance > 0n, }), - ]); + }), + getRawTokenBalance({ + address: signer.address, + tokenAddress: protocol.collateral.tradingToken, + }), + ]); - const gasStationDeficit = - eoaUsdceBalance >= MIN_GAS_STATION_USDCE_BALANCE_RAW + const gasTokenDeficit = + eoaPusdBalance >= MIN_PUSD_BALANCE_FOR_CLAIM_GAS_RAW ? 0n - : MIN_GAS_STATION_USDCE_BALANCE_RAW - eoaUsdceBalance; + : MIN_PUSD_BALANCE_FOR_CLAIM_GAS_RAW - eoaPusdBalance; const transactions = compileClaimTransactions({ protocol, @@ -136,14 +146,14 @@ export async function planClaim({ positions, safeAddress, missingRequirements, - safeUsdceBalance, - gasStationDeficit, + safeLegacyUsdceBalance, + gasTokenDeficit, }); return { - gasStationDeficit, - safeUsdceBalance, - eoaUsdceBalance, + gasTokenDeficit, + safeLegacyUsdceBalance, + eoaPusdBalance, missingRequirements, transactions, }; @@ -155,22 +165,22 @@ function compileClaimTransactions({ positions, safeAddress, missingRequirements, - safeUsdceBalance, - gasStationDeficit, + safeLegacyUsdceBalance, + gasTokenDeficit, }: { protocol?: PolymarketV2ProtocolDefinition; signer: Signer; positions: PredictPosition[]; safeAddress: string; missingRequirements: V2AllowanceRequirement[]; - safeUsdceBalance: bigint; - gasStationDeficit: bigint; + safeLegacyUsdceBalance: bigint; + gasTokenDeficit: bigint; }): SafeTransaction[] { const transactions = compileAllowanceMaintenanceTransactions({ protocol, safeAddress, missingRequirements, - usdceBalance: safeUsdceBalance, + usdceBalance: safeLegacyUsdceBalance, }); transactions.push( @@ -180,14 +190,16 @@ function compileClaimTransactions({ }), ); - const unwrapTransaction = buildUnwrapTransaction({ - recipientAddress: signer.address, - amount: gasStationDeficit, - protocol, - }); - - if (unwrapTransaction) { - transactions.push(unwrapTransaction); + if (gasTokenDeficit > 0n) { + transactions.push({ + to: protocol.collateral.tradingToken, + data: encodeErc20Transfer({ + to: signer.address, + value: gasTokenDeficit, + }), + operation: OperationType.Call, + value: '0', + }); } return transactions; @@ -198,17 +210,20 @@ export async function buildClaimTransaction({ positions, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance, }: { signer: Signer; positions: PredictPosition[]; safeAddress: string; protocol?: PolymarketV2ProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }) { const plan = await planClaim({ signer, positions, safeAddress, protocol, + safeLegacyUsdceBalance, }); return buildSignedSafeExecution({ diff --git a/app/components/UI/Predict/providers/polymarket/preflight/core.ts b/app/components/UI/Predict/providers/polymarket/preflight/core.ts index 4c1718a6613..db9e5d80581 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/core.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/core.ts @@ -5,7 +5,7 @@ import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from '../protocol/definitions'; -import { encodeUnwrap, encodeWrap } from '../protocol/orderCodec'; +import { encodeWrap } from '../protocol/orderCodec'; import { OperationType, type SafeTransaction } from '../safe/types'; import { aggregateTransaction, @@ -61,7 +61,7 @@ export function buildWrapTransaction({ amount: bigint; protocol?: PolymarketProtocolDefinition; }): SafeTransaction | undefined { - if (amount <= 0n || protocol.collateral.onrampAddress === undefined) { + if (amount <= 0n) { return undefined; } @@ -77,29 +77,18 @@ export function buildWrapTransaction({ }; } -export function buildUnwrapTransaction({ - recipientAddress, - amount, - protocol = POLYMARKET_V2_PROTOCOL, +function isLegacySweepRequirement({ + requirement, + protocol, }: { - recipientAddress: string; - amount: bigint; - protocol?: PolymarketProtocolDefinition; -}): SafeTransaction | undefined { - if (amount <= 0n || protocol.collateral.offrampAddress === undefined) { - return undefined; - } - - return { - to: protocol.collateral.offrampAddress, - data: encodeUnwrap({ - asset: protocol.collateral.legacyUsdceToken, - to: recipientAddress, - amount, - }), - operation: OperationType.Call, - value: '0', - }; + requirement: V2AllowanceRequirement; + protocol: PolymarketProtocolDefinition; +}): boolean { + return ( + requirement.type === 'erc20-allowance' && + requirement.tokenAddress === protocol.collateral.legacyUsdceToken && + requirement.spender === protocol.collateral.onrampAddress + ); } export function compileAllowanceMaintenanceTransactions({ @@ -113,7 +102,11 @@ export function compileAllowanceMaintenanceTransactions({ usdceBalance: bigint; protocol?: PolymarketProtocolDefinition; }): SafeTransaction[] { - const transactions = compileRequirementTransactions(missingRequirements); + const requirements = missingRequirements.filter( + (requirement) => + usdceBalance > 0n || !isLegacySweepRequirement({ requirement, protocol }), + ); + const transactions = compileRequirementTransactions(requirements); const wrapTransaction = buildWrapTransaction({ safeAddress, amount: usdceBalance, diff --git a/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts b/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts index b053b1cfb3b..c266170b333 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts @@ -11,10 +11,14 @@ import { getRawTokenBalance, } from './core'; import { inspectMissingRequirements } from './inspectMissingRequirements'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; export interface DepositMaintenancePlan { - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; preExistingSafeUsdceBalance: bigint; transactions: SafeTransaction[]; } @@ -22,20 +26,26 @@ export interface DepositMaintenancePlan { export async function planDepositMaintenance({ safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + preExistingSafeUsdceBalance: providedPreExistingSafeUsdceBalance, }: { safeAddress: string; protocol?: PolymarketProtocolDefinition; + preExistingSafeUsdceBalance?: bigint; }): Promise { - const [missingRequirements, preExistingSafeUsdceBalance] = await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getCanonicalV2AllowanceRequirements(protocol), - }), - getRawTokenBalance({ + const preExistingSafeUsdceBalance = + providedPreExistingSafeUsdceBalance ?? + (await getRawTokenBalance({ address: safeAddress, tokenAddress: protocol.collateral.legacyUsdceToken, - }), - ]); + })); + const requirements = + preExistingSafeUsdceBalance > 0n + ? getCanonicalV2AllowanceRequirements(protocol) + : getActiveV2AllowanceRequirements(protocol); + const missingRequirements = await inspectMissingRequirements({ + address: safeAddress, + requirements, + }); return { missingRequirements, @@ -57,7 +67,7 @@ function compileDepositMaintenanceTransactions({ }: { protocol?: PolymarketProtocolDefinition; safeAddress: string; - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; preExistingSafeUsdceBalance: bigint; }): SafeTransaction[] { return compileAllowanceMaintenanceTransactions({ @@ -72,12 +82,18 @@ export async function buildDepositMaintenanceTransaction({ signer, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + preExistingSafeUsdceBalance, }: { signer: Signer; safeAddress: string; protocol?: PolymarketProtocolDefinition; + preExistingSafeUsdceBalance?: bigint; }) { - const plan = await planDepositMaintenance({ safeAddress, protocol }); + const plan = await planDepositMaintenance({ + safeAddress, + protocol, + preExistingSafeUsdceBalance, + }); return buildSignedSafeExecutionIfNeeded({ signer, diff --git a/app/components/UI/Predict/providers/polymarket/preflight/trade.ts b/app/components/UI/Predict/providers/polymarket/preflight/trade.ts index b3e8358b580..0b9ecf88a5e 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/trade.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/trade.ts @@ -11,10 +11,14 @@ import { getRawTokenBalance, } from './core'; import { inspectMissingRequirements } from './inspectMissingRequirements'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; export interface TradePreflightPlan { - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; safeUsdceBalance: bigint; transactions: SafeTransaction[]; } @@ -22,20 +26,26 @@ export interface TradePreflightPlan { export async function planTradePreflight({ safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeUsdceBalance: providedSafeUsdceBalance, }: { safeAddress: string; protocol?: PolymarketProtocolDefinition; + safeUsdceBalance?: bigint; }): Promise { - const [missingRequirements, safeUsdceBalance] = await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getCanonicalV2AllowanceRequirements(protocol), - }), - getRawTokenBalance({ + const safeUsdceBalance = + providedSafeUsdceBalance ?? + (await getRawTokenBalance({ address: safeAddress, tokenAddress: protocol.collateral.legacyUsdceToken, - }), - ]); + })); + const requirements = + safeUsdceBalance > 0n + ? getCanonicalV2AllowanceRequirements(protocol) + : getActiveV2AllowanceRequirements(protocol); + const missingRequirements = await inspectMissingRequirements({ + address: safeAddress, + requirements, + }); return { missingRequirements, @@ -57,7 +67,7 @@ export function compileTradePreflightTransactions({ }: { protocol?: PolymarketProtocolDefinition; safeAddress: string; - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; safeUsdceBalance: bigint; }): SafeTransaction[] { return compileAllowanceMaintenanceTransactions({ @@ -72,14 +82,17 @@ export async function buildTradeAllowancesTx({ signer, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeUsdceBalance, }: { signer: Signer; safeAddress: string; protocol?: PolymarketProtocolDefinition; + safeUsdceBalance?: bigint; }): Promise<{ to: string; data: string } | undefined> { const plan = await planTradePreflight({ safeAddress, protocol, + safeUsdceBalance, }); const signedExecution = await buildSignedSafeExecutionIfNeeded({ diff --git a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts index 08e53ad3f6c..2c6ff66433c 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts @@ -1,12 +1,26 @@ import { PERMIT2_ADDRESS } from '../safe/constants'; import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, +} from './v2AllowanceRequirements'; describe('v2 allowance requirements', () => { + it('returns active v2 requirements without the legacy sweep requirement', () => { + const requirements = getActiveV2AllowanceRequirements(); + + expect(requirements).toHaveLength(8); + expect(requirements).not.toContainEqual({ + type: 'erc20-allowance', + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + spender: POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + }); + }); + it('returns the canonical requirement list in deterministic order', () => { const requirements = getCanonicalV2AllowanceRequirements(); - expect(requirements).toHaveLength(10); + expect(requirements).toHaveLength(9); expect(requirements[0]).toEqual({ type: 'erc20-allowance', tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, @@ -18,10 +32,6 @@ describe('v2 allowance requirements', () => { type: 'erc20-allowance', spender: PERMIT2_ADDRESS, }), - expect.objectContaining({ - type: 'erc20-allowance', - spender: POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, - }), expect.objectContaining({ type: 'erc1155-operator', operator: POLYMARKET_V2_PROTOCOL.contracts.exchange, diff --git a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts index 9989616e635..b83b7538777 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts @@ -48,23 +48,27 @@ function buildErc1155OperatorRequirements({ })); } -export function getCanonicalV2AllowanceRequirements( +export function getLegacySweepAllowanceRequirements( protocol: PolymarketProtocolDefinition = POLYMARKET_V2_PROTOCOL, ): V2AllowanceRequirement[] { - const { collateral, contracts } = protocol; - - if (!collateral.onrampAddress || !collateral.offrampAddress) { - throw new Error( - 'Polymarket CLOB v2 collateral ramp addresses are required', - ); - } + const { collateral } = protocol; return [ + // Temporary legacy Safe USDC.e -> pUSD sweep support. TODO: remove after one release. { type: 'erc20-allowance', tokenAddress: collateral.legacyUsdceToken, spender: collateral.onrampAddress, }, + ]; +} + +export function getActiveV2AllowanceRequirements( + protocol: PolymarketProtocolDefinition = POLYMARKET_V2_PROTOCOL, +): V2AllowanceRequirement[] { + const { collateral, contracts } = protocol; + + return [ ...buildErc20AllowanceRequirements({ tokenAddress: collateral.tradingToken, spenders: [ @@ -73,7 +77,6 @@ export function getCanonicalV2AllowanceRequirements( contracts.negRiskExchange, contracts.negRiskAdapter, PERMIT2_ADDRESS, - collateral.offrampAddress, ], }), ...buildErc1155OperatorRequirements({ @@ -86,3 +89,12 @@ export function getCanonicalV2AllowanceRequirements( }), ]; } + +export function getCanonicalV2AllowanceRequirements( + protocol: PolymarketProtocolDefinition = POLYMARKET_V2_PROTOCOL, +): V2AllowanceRequirement[] { + return [ + ...getLegacySweepAllowanceRequirements(protocol), + ...getActiveV2AllowanceRequirements(protocol), + ]; +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts index 82c2635e5ad..b2c236b9dc6 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts @@ -1,20 +1,12 @@ -jest.mock('./core', () => ({ - buildSignedSafeExecution: jest.fn(), - buildUnwrapTransaction: jest.fn(({ amount, protocol, recipientAddress }) => { - if (amount === 0n || !protocol?.collateral.offrampAddress) { - return undefined; - } - - return { - to: protocol.collateral.offrampAddress, - data: '0xunwrap', - operation: 0, - value: '0', - recipientAddress, - }; - }), - getRawTokenBalance: jest.fn(), -})); +jest.mock('./core', () => { + const actual = jest.requireActual('./core'); + + return { + ...actual, + buildSignedSafeExecution: jest.fn(), + getRawTokenBalance: jest.fn(), + }; +}); jest.mock('./inspectMissingRequirements', () => ({ inspectMissingRequirements: jest.fn().mockResolvedValue([]), @@ -24,21 +16,22 @@ jest.mock('./compileRequirementTransactions', () => ({ compileRequirementTransactions: jest.fn(() => []), })); -jest.mock('../protocol/orderCodec', () => ({ - encodeUnwrap: jest.fn(() => '0xunwrap'), -})); - jest.mock('../utils', () => ({ encodeErc20Transfer: jest.fn(() => '0xtransfer'), })); import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; import { getRawTokenBalance } from './core'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; import { planWithdraw } from './withdraw'; const mockGetRawTokenBalance = getRawTokenBalance as jest.MockedFunction< typeof getRawTokenBalance >; +const mockInspectMissingRequirements = + inspectMissingRequirements as jest.MockedFunction< + typeof inspectMissingRequirements + >; const signer = { address: '0x1111111111111111111111111111111111111111', @@ -51,69 +44,37 @@ describe('planWithdraw', () => { jest.clearAllMocks(); }); - it('does not read Safe pUSD when the Safe already has enough USDC.e', async () => { + it('sweeps legacy Safe USDC.e state and transfers pUSD directly', async () => { mockGetRawTokenBalance.mockResolvedValueOnce(1_000_000n); const plan = await planWithdraw({ signer, safeAddress: '0x9999999999999999999999999999999999999999', requestedAmountRaw: 1_000_000n, - mode: 'usdce-deficit-unwrap', protocol: POLYMARKET_V2_PROTOCOL, }); - expect(plan.deficit).toBe(0n); + expect(plan.safeLegacyUsdceBalance).toBe(1_000_000n); expect(mockGetRawTokenBalance).toHaveBeenCalledTimes(1); expect(mockGetRawTokenBalance).toHaveBeenCalledWith({ address: '0x9999999999999999999999999999999999999999', tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, }); - }); - - it('allows fallback withdraw when Safe pUSD covers the exact deficit', async () => { - mockGetRawTokenBalance - .mockResolvedValueOnce(500_000n) - .mockResolvedValueOnce(500_000n); - - const plan = await planWithdraw({ - signer, - safeAddress: '0x9999999999999999999999999999999999999999', - requestedAmountRaw: 1_000_000n, - mode: 'usdce-deficit-unwrap', - protocol: POLYMARKET_V2_PROTOCOL, - }); - - expect(plan.deficit).toBe(500_000n); - expect(mockGetRawTokenBalance).toHaveBeenCalledTimes(2); - expect(mockGetRawTokenBalance.mock.calls[1]?.[0]).toEqual({ + expect(mockInspectMissingRequirements).toHaveBeenCalledWith({ address: '0x9999999999999999999999999999999999999999', - tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + requirements: expect.arrayContaining([ + expect.objectContaining({ + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + spender: POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + }), + expect.objectContaining({ + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + }), + ]), }); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ - POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, ]); }); - - it('throws when Safe pUSD is below the exact deficit', async () => { - mockGetRawTokenBalance - .mockResolvedValueOnce(500_000n) - .mockResolvedValueOnce(499_999n); - - await expect( - planWithdraw({ - signer, - safeAddress: '0x9999999999999999999999999999999999999999', - requestedAmountRaw: 1_000_000n, - mode: 'usdce-deficit-unwrap', - protocol: POLYMARKET_V2_PROTOCOL, - }), - ).rejects.toThrow('Insufficient Safe pUSD balance for fallback withdraw'); - - expect(mockGetRawTokenBalance).toHaveBeenCalledTimes(2); - expect(mockGetRawTokenBalance.mock.calls[1]?.[0]).toEqual({ - address: '0x9999999999999999999999999999999999999999', - tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, - }); - }); }); diff --git a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts index fff3cc1be12..7345ad8742c 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts @@ -3,24 +3,25 @@ import type { Signer } from '../../types'; import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, - type WithdrawExecutionMode, } from '../protocol/definitions'; import { OperationType, type SafeTransaction } from '../safe/types'; import { encodeErc20Transfer } from '../utils'; import { buildSignedSafeExecution, - buildUnwrapTransaction, + compileAllowanceMaintenanceTransactions, getRawTokenBalance, } from './core'; -import { compileRequirementTransactions } from './compileRequirementTransactions'; import { inspectMissingRequirements } from './inspectMissingRequirements'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; export interface WithdrawPlan { requestedAmountRaw: bigint; - safeUsdceBalance: bigint; - deficit: bigint; - missingRequirements: ReturnType; + safeLegacyUsdceBalance: bigint; + missingRequirements: V2AllowanceRequirement[]; transactions: SafeTransaction[]; } @@ -28,54 +29,40 @@ export async function planWithdraw({ signer, safeAddress, requestedAmountRaw, - mode, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance: providedSafeLegacyUsdceBalance, }: { signer: Signer; safeAddress: string; requestedAmountRaw: bigint; - mode: WithdrawExecutionMode; protocol?: PolymarketProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }): Promise { - const [missingRequirements, safeUsdceBalance] = await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getCanonicalV2AllowanceRequirements(protocol), - }), - getRawTokenBalance({ + const safeLegacyUsdceBalance = + providedSafeLegacyUsdceBalance ?? + (await getRawTokenBalance({ address: safeAddress, tokenAddress: protocol.collateral.legacyUsdceToken, - }), - ]); - - const deficit = - mode === 'usdce-deficit-unwrap' && requestedAmountRaw > safeUsdceBalance - ? requestedAmountRaw - safeUsdceBalance - : 0n; - - if (mode === 'usdce-deficit-unwrap' && deficit > 0n) { - const safePusdBalance = await getRawTokenBalance({ - address: safeAddress, - tokenAddress: protocol.collateral.tradingToken, - }); - - if (safePusdBalance < deficit) { - throw new Error('Insufficient Safe pUSD balance for fallback withdraw'); - } - } + })); + const requirements = + safeLegacyUsdceBalance > 0n + ? getCanonicalV2AllowanceRequirements(protocol) + : getActiveV2AllowanceRequirements(protocol); + const missingRequirements = await inspectMissingRequirements({ + address: safeAddress, + requirements, + }); return { requestedAmountRaw, - safeUsdceBalance, - deficit, + safeLegacyUsdceBalance, missingRequirements, transactions: compileWithdrawTransactions({ signer, safeAddress, requestedAmountRaw, - deficit, missingRequirements, - mode, + safeLegacyUsdceBalance, protocol, }), }; @@ -83,49 +70,28 @@ export async function planWithdraw({ function compileWithdrawTransactions({ signer, - safeAddress, requestedAmountRaw, - deficit, + safeAddress, missingRequirements, - mode, + safeLegacyUsdceBalance, protocol = POLYMARKET_V2_PROTOCOL, }: { signer: Signer; safeAddress: string; requestedAmountRaw: bigint; - deficit: bigint; - missingRequirements: ReturnType; - mode: WithdrawExecutionMode; + missingRequirements: V2AllowanceRequirement[]; + safeLegacyUsdceBalance: bigint; protocol?: PolymarketProtocolDefinition; }): SafeTransaction[] { - const transactions = compileRequirementTransactions(missingRequirements); - - if (mode === 'pusd-transfer') { - transactions.push({ - to: protocol.collateral.tradingToken, - data: encodeErc20Transfer({ - to: signer.address, - value: requestedAmountRaw, - }), - operation: OperationType.Call, - value: '0', - }); - - return transactions; - } - - const unwrapTransaction = buildUnwrapTransaction({ - recipientAddress: safeAddress, - amount: deficit, + const transactions = compileAllowanceMaintenanceTransactions({ protocol, + safeAddress, + missingRequirements, + usdceBalance: safeLegacyUsdceBalance, }); - if (unwrapTransaction) { - transactions.push(unwrapTransaction); - } - transactions.push({ - to: protocol.collateral.legacyUsdceToken, + to: protocol.collateral.tradingToken, data: encodeErc20Transfer({ to: signer.address, value: requestedAmountRaw, @@ -141,21 +107,21 @@ export async function buildWithdrawTransaction({ signer, safeAddress, requestedAmountRaw, - mode, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance, }: { signer: Signer; safeAddress: string; requestedAmountRaw: bigint; - mode: WithdrawExecutionMode; protocol?: PolymarketProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }) { const plan = await planWithdraw({ signer, safeAddress, requestedAmountRaw, - mode, protocol, + safeLegacyUsdceBalance, }); return buildSignedSafeExecution({ diff --git a/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts index be681f4c8d5..98e68da4c9d 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts @@ -1,6 +1,6 @@ import { parseUnits } from 'ethers/lib/utils'; import { PredictPositionStatus, type PredictPosition } from '../../../types'; -import { MIN_COLLATERAL_BALANCE_FOR_CLAIM } from '../constants'; +import { MIN_PUSD_BALANCE_FOR_CLAIM_GAS } from '../constants'; import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; import { planClaim, getClaimRequirements } from './claim'; import { getRawTokenBalance } from './core'; @@ -67,7 +67,7 @@ const signer = { }; const gasStationThresholdRaw = BigInt( - parseUnits(MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), 6).toString(), + parseUnits(MIN_PUSD_BALANCE_FOR_CLAIM_GAS.toString(), 6).toString(), ); describe('preflight workflow planners', () => { @@ -109,7 +109,20 @@ describe('preflight workflow planners', () => { ); }); - it('builds claim transactions as repairs, wrap, adapter claim, then exact-deficit unwrap', async () => { + it('builds deposit maintenance allowance repairs even without legacy balance', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(0n); + + const plan = await planDepositMaintenance({ + protocol: POLYMARKET_V2_PROTOCOL, + safeAddress: '0x1111111111111111111111111111111111111111', + }); + + expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ + '0x1000000000000000000000000000000000000000', + ]); + }); + + it('builds claim transactions as repairs, wrap, adapter claim, then exact pUSD gas transfer', async () => { mockGetRawTokenBalance.mockResolvedValueOnce(10n).mockResolvedValueOnce(0n); const plan = await planClaim({ @@ -119,12 +132,12 @@ describe('preflight workflow planners', () => { safeAddress: '0x9999999999999999999999999999999999999999', }); - expect(plan.gasStationDeficit).toBe(gasStationThresholdRaw); + expect(plan.gasTokenDeficit).toBe(gasStationThresholdRaw); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ '0x1000000000000000000000000000000000000000', POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, POLYMARKET_V2_PROTOCOL.claim.standardTarget, - POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, ]); }); @@ -181,35 +194,31 @@ describe('preflight workflow planners', () => { ); }); - it('builds withdraw fallback as repairs, optional unwrap, then usdce transfer', async () => { - mockGetRawTokenBalance - .mockResolvedValueOnce(1_000_000n) - .mockResolvedValueOnce(1_000_000n); + it('builds withdraw as repairs, wrap, then pUSD transfer', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(1_000_000n); const plan = await planWithdraw({ protocol: POLYMARKET_V2_PROTOCOL, signer, safeAddress: '0x9999999999999999999999999999999999999999', requestedAmountRaw: BigInt(parseUnits('2', 6).toString()), - mode: 'usdce-deficit-unwrap', }); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ '0x1000000000000000000000000000000000000000', - POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, ]); }); - it('builds withdraw preferred mode as repairs followed by pusd transfer', async () => { - mockGetRawTokenBalance.mockResolvedValueOnce(1_000_000n); + it('builds withdraw allowance repairs even without legacy balance', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(0n); const plan = await planWithdraw({ protocol: POLYMARKET_V2_PROTOCOL, signer, safeAddress: '0x9999999999999999999999999999999999999999', requestedAmountRaw: BigInt(parseUnits('2', 6).toString()), - mode: 'pusd-transfer', }); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ diff --git a/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts index 7e820cc1571..18e894e84b4 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts @@ -2,17 +2,16 @@ import { CTF_COLLATERAL_ADAPTER_ADDRESS, DEFAULT_CLOB_BASE_URL, HASH_ZERO_BYTES32, - LEGACY_V2_CLOB_BASE_URL, + MATIC_CONTRACTS_V2, NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, + USDC_E_ADDRESS, } from '../constants'; import Logger from '../../../../../../util/Logger'; import { - POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL, getClobV2BuilderCode, getProtocolDepositTokenAddress, getProtocolWithdrawTokenAddress, - resolvePolymarketProtocol, } from './definitions'; describe('polymarket protocol definitions', () => { @@ -37,38 +36,35 @@ describe('polymarket protocol definitions', () => { process.env.MM_PREDICT_BUILDER_CODE = originalBuilderCode; }); - it('resolves v1 when predictClobV2 is disabled', () => { - expect(resolvePolymarketProtocol({ predictClobV2Enabled: false })).toBe( - POLYMARKET_V1_PROTOCOL, + it('defines CLOB v2 as the only protocol', () => { + expect(POLYMARKET_V2_PROTOCOL).toEqual( + expect.objectContaining({ + key: 'v2', + contracts: MATIC_CONTRACTS_V2, + transport: { + clobBaseUrl: DEFAULT_CLOB_BASE_URL, + clobVersionHeader: '2', + }, + workflow: { + depositMode: 'pusd-transfer', + withdrawMode: 'pusd-transfer', + }, + }), ); }); - it('resolves v2 when predictClobV2 is enabled', () => { - expect(resolvePolymarketProtocol({ predictClobV2Enabled: true })).toBe( - POLYMARKET_V2_PROTOCOL, + it('keeps legacy USDC.e only as sweep collateral state', () => { + expect(POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken).toBe( + USDC_E_ADDRESS, ); - }); - - it('defaults the v2 protocol to the canonical CLOB host', () => { - expect(POLYMARKET_V2_PROTOCOL.transport.clobBaseUrl).toBe( - DEFAULT_CLOB_BASE_URL, + expect(POLYMARKET_V2_PROTOCOL.collateral.tradingToken).toBe( + MATIC_CONTRACTS_V2.collateral, ); - }); - - it('resolves a temporary v2 CLOB host override from feature flags', () => { - expect( - resolvePolymarketProtocol({ - predictClobV2Enabled: true, - predictClobV2ClobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }), - ).toEqual( - expect.objectContaining({ - key: 'v2', - transport: expect.objectContaining({ - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - clobVersionHeader: '2', - }), - }), + expect(POLYMARKET_V2_PROTOCOL.collateral.claimToken).toBe( + MATIC_CONTRACTS_V2.collateral, + ); + expect(POLYMARKET_V2_PROTOCOL.collateral.feeAuthorizationToken).toBe( + MATIC_CONTRACTS_V2.collateral, ); }); @@ -100,28 +96,19 @@ describe('polymarket protocol definitions', () => { ); }); - it('routes v2 claims through the collateral adapters', () => { + it('routes claims through the collateral adapters', () => { expect(POLYMARKET_V2_PROTOCOL.claim).toEqual({ standardTarget: CTF_COLLATERAL_ADAPTER_ADDRESS, negRiskTarget: NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, }); }); - it('returns the configured deposit token address for each protocol', () => { - expect(getProtocolDepositTokenAddress(POLYMARKET_V1_PROTOCOL)).toBe( - POLYMARKET_V1_PROTOCOL.collateral.legacyUsdceToken, - ); + it('returns pUSD for deposit and withdraw token addresses', () => { expect(getProtocolDepositTokenAddress(POLYMARKET_V2_PROTOCOL)).toBe( - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, - ); - }); - - it('returns the configured withdraw token address for each protocol', () => { - expect(getProtocolWithdrawTokenAddress(POLYMARKET_V1_PROTOCOL)).toBe( - POLYMARKET_V1_PROTOCOL.collateral.legacyUsdceToken, + MATIC_CONTRACTS_V2.collateral, ); expect(getProtocolWithdrawTokenAddress(POLYMARKET_V2_PROTOCOL)).toBe( - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + MATIC_CONTRACTS_V2.collateral, ); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts b/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts index c653350e3e8..978885a7d36 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts @@ -1,11 +1,8 @@ import type { ContractConfig } from '../types'; -import type { PredictFeatureFlags } from '../../../types/flags'; import { HASH_ZERO_BYTES32, - MATIC_CONTRACTS, MATIC_CONTRACTS_V2, DEFAULT_CLOB_BASE_URL, - COLLATERAL_OFFRAMP_ADDRESS, COLLATERAL_ONRAMP_ADDRESS, CTF_COLLATERAL_ADAPTER_ADDRESS, NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, @@ -13,32 +10,31 @@ import { } from '../constants'; import Logger from '../../../../../../util/Logger'; -export type PolymarketProtocolKey = 'v1' | 'v2'; -export type DepositExecutionMode = 'usdce-transfer' | 'pusd-transfer'; -export type WithdrawExecutionMode = - | 'usdce-transfer' - | 'usdce-deficit-unwrap' - | 'pusd-transfer'; +export type PolymarketProtocolKey = 'v2'; +export type DepositExecutionMode = 'pusd-transfer'; +export type WithdrawExecutionMode = 'pusd-transfer'; interface BasePolymarketProtocolDefinition { key: PolymarketProtocolKey; contracts: ContractConfig; collateral: { + /** + * Legacy Safe USDC.e is hidden from user-facing flows and only used for the + * one-release opportunistic sweep into pUSD. TODO: remove after sweep window. + */ legacyUsdceToken: string; tradingToken: string; claimToken: string; feeAuthorizationToken: string; - balanceTokens: string[]; - onrampAddress?: string; - offrampAddress?: string; + onrampAddress: string; }; order: { - domainVersion: '1' | '2'; + domainVersion: '2'; metadata: string; - getBuilderCode?: () => string; + getBuilderCode: () => string; }; transport: { - clobVersionHeader?: '2'; + clobVersionHeader: '2'; clobBaseUrl: string; }; workflow: { @@ -71,37 +67,6 @@ export function getClobV2BuilderCode(): string { return HASH_ZERO_BYTES32; } -export const POLYMARKET_V1_PROTOCOL = { - key: 'v1', - contracts: MATIC_CONTRACTS, - collateral: { - legacyUsdceToken: MATIC_CONTRACTS.collateral, - tradingToken: MATIC_CONTRACTS.collateral, - claimToken: MATIC_CONTRACTS.collateral, - feeAuthorizationToken: MATIC_CONTRACTS.collateral, - balanceTokens: [MATIC_CONTRACTS.collateral], - onrampAddress: undefined, - offrampAddress: undefined, - }, - order: { - domainVersion: '1', - metadata: HASH_ZERO_BYTES32, - getBuilderCode: undefined, - }, - transport: { - clobVersionHeader: undefined, - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }, - workflow: { - depositMode: 'usdce-transfer', - withdrawMode: 'usdce-transfer', - }, - claim: { - standardTarget: MATIC_CONTRACTS.conditionalTokens, - negRiskTarget: MATIC_CONTRACTS.negRiskAdapter, - }, -} satisfies BasePolymarketProtocolDefinition; - export const POLYMARKET_V2_PROTOCOL = { key: 'v2', contracts: MATIC_CONTRACTS_V2, @@ -110,9 +75,7 @@ export const POLYMARKET_V2_PROTOCOL = { tradingToken: MATIC_CONTRACTS_V2.collateral, claimToken: MATIC_CONTRACTS_V2.collateral, feeAuthorizationToken: MATIC_CONTRACTS_V2.collateral, - balanceTokens: [USDC_E_ADDRESS, MATIC_CONTRACTS_V2.collateral], onrampAddress: COLLATERAL_ONRAMP_ADDRESS, - offrampAddress: COLLATERAL_OFFRAMP_ADDRESS, }, order: { domainVersion: '2', @@ -124,8 +87,8 @@ export const POLYMARKET_V2_PROTOCOL = { clobBaseUrl: DEFAULT_CLOB_BASE_URL, }, workflow: { - depositMode: 'usdce-transfer', - withdrawMode: 'usdce-deficit-unwrap', + depositMode: 'pusd-transfer', + withdrawMode: 'pusd-transfer', }, claim: { standardTarget: CTF_COLLATERAL_ADAPTER_ADDRESS, @@ -133,61 +96,16 @@ export const POLYMARKET_V2_PROTOCOL = { }, } satisfies BasePolymarketProtocolDefinition; -export type PolymarketProtocolDefinition = - | typeof POLYMARKET_V1_PROTOCOL - | typeof POLYMARKET_V2_PROTOCOL; +export type PolymarketProtocolDefinition = typeof POLYMARKET_V2_PROTOCOL; export function getProtocolDepositTokenAddress( protocol: PolymarketProtocolDefinition, ): string { - const depositMode = protocol.workflow.depositMode as DepositExecutionMode; - - switch (depositMode) { - case 'pusd-transfer': - return protocol.collateral.tradingToken; - case 'usdce-transfer': - default: - return protocol.collateral.legacyUsdceToken; - } + return protocol.collateral.tradingToken; } export function getProtocolWithdrawTokenAddress( protocol: PolymarketProtocolDefinition, ): string { - const withdrawMode = protocol.workflow.withdrawMode as WithdrawExecutionMode; - - switch (withdrawMode) { - case 'pusd-transfer': - return protocol.collateral.tradingToken; - case 'usdce-transfer': - case 'usdce-deficit-unwrap': - default: - return protocol.collateral.legacyUsdceToken; - } -} - -export function resolvePolymarketProtocol( - featureFlags: Pick< - PredictFeatureFlags, - 'predictClobV2Enabled' | 'predictClobV2ClobBaseUrl' - >, -): PolymarketProtocolDefinition { - if (!featureFlags.predictClobV2Enabled) { - return POLYMARKET_V1_PROTOCOL; - } - - const clobBaseUrl = - featureFlags.predictClobV2ClobBaseUrl ?? DEFAULT_CLOB_BASE_URL; - - if (clobBaseUrl === POLYMARKET_V2_PROTOCOL.transport.clobBaseUrl) { - return POLYMARKET_V2_PROTOCOL; - } - - return { - ...POLYMARKET_V2_PROTOCOL, - transport: { - ...POLYMARKET_V2_PROTOCOL.transport, - clobBaseUrl, - }, - }; + return protocol.collateral.tradingToken; } diff --git a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts index 6fb226b1ef3..7bd4f2fb5f8 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts @@ -1,9 +1,8 @@ import { Side, type OrderPreview } from '../../../types'; import { OrderType } from '../types'; -import { POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL } from './definitions'; +import { POLYMARKET_V2_PROTOCOL } from './definitions'; import { buildProtocolUnsignedOrder, - encodeUnwrap, encodeWrap, getPreviewFeeRateBpsForProtocol, getProtocolOrderTypedData, @@ -28,7 +27,7 @@ const preview: OrderPreview = { }; describe('polymarket protocol order codec', () => { - const protocolV2 = { + const protocol = { ...POLYMARKET_V2_PROTOCOL, order: { ...POLYMARKET_V2_PROTOCOL.order, @@ -37,25 +36,9 @@ describe('polymarket protocol order codec', () => { }, }; - it('builds a v1 order with v1-only fields', () => { - const order = buildProtocolUnsignedOrder({ - protocol: POLYMARKET_V1_PROTOCOL, - preview, - makerAddress: '0x1111111111111111111111111111111111111111', - signerAddress: '0x2222222222222222222222222222222222222222', - nowInSeconds: 123, - }); - - expect(order).toHaveProperty('taker'); - expect(order).toHaveProperty('nonce', '0'); - expect(order).toHaveProperty('feeRateBps', '77'); - expect(order).not.toHaveProperty('metadata'); - expect(order).not.toHaveProperty('builder'); - }); - it('builds a v2 order with timestamp, metadata, and builder', () => { const order = buildProtocolUnsignedOrder({ - protocol: protocolV2, + protocol, preview, makerAddress: '0x1111111111111111111111111111111111111111', signerAddress: '0x2222222222222222222222222222222222222222', @@ -92,7 +75,7 @@ describe('polymarket protocol order codec', () => { it('builds v2 typed data with domain version 2 and bytes32 fields', () => { const order = buildProtocolUnsignedOrder({ - protocol: protocolV2, + protocol, preview, makerAddress: '0x1111111111111111111111111111111111111111', signerAddress: '0x2222222222222222222222222222222222222222', @@ -100,17 +83,17 @@ describe('polymarket protocol order codec', () => { }); const typedData = getProtocolOrderTypedData({ - protocol: protocolV2, + protocol, order, verifyingContract: getProtocolVerifyingContract({ - protocol: protocolV2, + protocol, negRisk: true, }), }); expect(typedData.domain.version).toBe('2'); expect(typedData.domain.verifyingContract).toBe( - protocolV2.contracts.negRiskExchange, + protocol.contracts.negRiskExchange, ); expect(typedData.types.Order).toEqual( expect.arrayContaining([ @@ -122,7 +105,7 @@ describe('polymarket protocol order codec', () => { it('serializes signed orders into the relayer body shape', () => { const order = buildProtocolUnsignedOrder({ - protocol: protocolV2, + protocol, preview, makerAddress: '0x1111111111111111111111111111111111111111', signerAddress: '0x2222222222222222222222222222222222222222', @@ -152,34 +135,14 @@ describe('polymarket protocol order codec', () => { ); }); - it('forces preview fee rate to zero under v2', () => { - expect( - getPreviewFeeRateBpsForProtocol({ - protocol: protocolV2, - preview, - }), - ).toBe('0'); - - expect( - getPreviewFeeRateBpsForProtocol({ - protocol: POLYMARKET_V1_PROTOCOL, - preview, - }), - ).toBe('77'); + it('forces preview fee rate to zero', () => { + expect(getPreviewFeeRateBpsForProtocol()).toBe('0'); }); - it('encodes wrap and unwrap calls', () => { + it('encodes wrap calls used by the legacy USDC.e sweep', () => { expect( encodeWrap({ - asset: protocolV2.collateral.legacyUsdceToken, - to: '0x1111111111111111111111111111111111111111', - amount: 42n, - }), - ).toMatch(/^0x[0-9a-f]+$/u); - - expect( - encodeUnwrap({ - asset: protocolV2.collateral.legacyUsdceToken, + asset: protocol.collateral.legacyUsdceToken, to: '0x1111111111111111111111111111111111111111', amount: 42n, }), diff --git a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts index aad09dba6b5..5f77591c9df 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts @@ -7,9 +7,7 @@ import { ROUNDING_CONFIG, } from '../constants'; import { - type ClobOrderObject, - type OrderData, - OrderType, + type OrderType, SignatureType, type TickSize, UtilsSide, @@ -17,14 +15,7 @@ import { import { generateSalt, roundOrderAmount } from '../utils'; import type { PolymarketProtocolDefinition } from './definitions'; -export type V1ProtocolDefinition = Extract< - PolymarketProtocolDefinition, - { key: 'v1' } ->; -export type V2ProtocolDefinition = Extract< - PolymarketProtocolDefinition, - { key: 'v2' } ->; +export type ProtocolDefinition = PolymarketProtocolDefinition; export interface OrderDataV2 { maker: string; @@ -54,19 +45,10 @@ export interface ClobOrderObjectV2 { orderType: OrderType; } -export type ProtocolUnsignedOrderV1 = OrderData & { salt: string }; -export type ProtocolUnsignedOrderV2 = OrderDataV2 & { salt: string }; -export type ProtocolUnsignedOrder = - | ProtocolUnsignedOrderV1 - | ProtocolUnsignedOrderV2; -export type ProtocolSignedOrderV1 = ProtocolUnsignedOrderV1 & { - signature: string; -}; -export type ProtocolSignedOrderV2 = SignedOrderV2; -export type ProtocolSignedOrder = ProtocolSignedOrderV1 | ProtocolSignedOrderV2; -export type ProtocolRelayerOrder = ClobOrderObject | ClobOrderObjectV2; +export type ProtocolUnsignedOrder = OrderDataV2 & { salt: string }; +export type ProtocolSignedOrder = SignedOrderV2; +export type ProtocolRelayerOrder = ClobOrderObjectV2; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const ORDER_PRIMARY_TYPE = 'Order'; const ORDER_DOMAIN_NAME = 'Polymarket CTF Exchange'; const ORDER_DOMAIN_TYPES = [ @@ -91,41 +73,21 @@ function buildProtocolOrderDomain({ }; } -function getProtocolOrderTypes(protocol: PolymarketProtocolDefinition) { - if (protocol.key === 'v2') { - return { - EIP712Domain: ORDER_DOMAIN_TYPES, - Order: [ - { name: 'salt', type: 'uint256' }, - { name: 'maker', type: 'address' }, - { name: 'signer', type: 'address' }, - { name: 'tokenId', type: 'uint256' }, - { name: 'makerAmount', type: 'uint256' }, - { name: 'takerAmount', type: 'uint256' }, - { name: 'side', type: 'uint8' }, - { name: 'signatureType', type: 'uint8' }, - { name: 'timestamp', type: 'uint256' }, - { name: 'metadata', type: 'bytes32' }, - { name: 'builder', type: 'bytes32' }, - ], - }; - } - +function getProtocolOrderTypes() { return { EIP712Domain: ORDER_DOMAIN_TYPES, Order: [ { name: 'salt', type: 'uint256' }, { name: 'maker', type: 'address' }, { name: 'signer', type: 'address' }, - { name: 'taker', type: 'address' }, { name: 'tokenId', type: 'uint256' }, { name: 'makerAmount', type: 'uint256' }, { name: 'takerAmount', type: 'uint256' }, - { name: 'expiration', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'feeRateBps', type: 'uint256' }, { name: 'side', type: 'uint8' }, { name: 'signatureType', type: 'uint8' }, + { name: 'timestamp', type: 'uint256' }, + { name: 'metadata', type: 'bytes32' }, + { name: 'builder', type: 'bytes32' }, ], }; } @@ -153,32 +115,6 @@ function getTakerAmountWithSlippage(preview: OrderPreview): string { ).toString(); } -export function buildProtocolUnsignedOrder({ - protocol, - preview, - makerAddress, - signerAddress, - nowInSeconds, -}: { - protocol: V1ProtocolDefinition; - preview: OrderPreview; - makerAddress: string; - signerAddress: string; - nowInSeconds?: number; -}): ProtocolUnsignedOrderV1; -export function buildProtocolUnsignedOrder({ - protocol, - preview, - makerAddress, - signerAddress, - nowInSeconds, -}: { - protocol: V2ProtocolDefinition; - preview: OrderPreview; - makerAddress: string; - signerAddress: string; - nowInSeconds?: number; -}): ProtocolUnsignedOrderV2; export function buildProtocolUnsignedOrder({ protocol, preview, @@ -193,8 +129,7 @@ export function buildProtocolUnsignedOrder({ nowInSeconds?: number; }): ProtocolUnsignedOrder { // NOTE: Field order matters for EIP-712 signing. Do NOT use object spread - // (e.g. `...baseOrder`) to build these return objects — it causes fields like - // `taker` (v1) to land in the wrong position, resulting in an "invalid API" error. + // (e.g. `...baseOrder`) to build the return object. const salt = generateSalt(); const maker = makerAddress; const signer = signerAddress; @@ -206,41 +141,23 @@ export function buildProtocolUnsignedOrder({ const takerAmount = getTakerAmountWithSlippage(preview); const side = preview.side === Side.BUY ? UtilsSide.BUY : UtilsSide.SELL; const signatureType = SignatureType.POLY_GNOSIS_SAFE; + const builder = protocol.order.getBuilderCode(); - if (protocol.key === 'v2') { - const builder = protocol.order.getBuilderCode?.(); - - if (!builder) { - throw new Error('Missing Polymarket CLOB v2 builder code'); - } - - return { - salt, - maker, - signer, - tokenId, - makerAmount, - takerAmount, - expiration: '0', - timestamp: `${nowInSeconds}`, - metadata: protocol.order.metadata, - builder, - side, - signatureType, - }; + if (!builder) { + throw new Error('Missing Polymarket CLOB v2 builder code'); } return { salt, maker, signer, - taker: ZERO_ADDRESS, tokenId, makerAmount, takerAmount, expiration: '0', - nonce: '0', - feeRateBps: preview.feeRateBps ?? '0', + timestamp: `${nowInSeconds}`, + metadata: protocol.order.metadata, + builder, side, signatureType, }; @@ -276,33 +193,11 @@ export function getProtocolOrderTypedData({ verifyingContract, chainId, }), - types: getProtocolOrderTypes(protocol), + types: getProtocolOrderTypes(), message: order, }; } -export function serializeProtocolRelayerOrder({ - signedOrder, - owner, - orderType, - side, -}: { - signedOrder: ProtocolSignedOrderV1; - owner: string; - orderType: OrderType; - side: Side; -}): ClobOrderObject; -export function serializeProtocolRelayerOrder({ - signedOrder, - owner, - orderType, - side, -}: { - signedOrder: ProtocolSignedOrderV2; - owner: string; - orderType: OrderType; - side: Side; -}): ClobOrderObjectV2; export function serializeProtocolRelayerOrder({ signedOrder, owner, @@ -314,39 +209,19 @@ export function serializeProtocolRelayerOrder({ orderType: OrderType; side: Side; }): ProtocolRelayerOrder { - const order = { - ...signedOrder, - side, - salt: parseInt(signedOrder.salt), - }; - - if ('builder' in signedOrder) { - return { - order: order as ClobOrderObjectV2['order'], - owner, - orderType, - }; - } - return { - order: order as ClobOrderObject['order'], + order: { + ...signedOrder, + side, + salt: parseInt(signedOrder.salt), + }, owner, orderType, }; } -export function getPreviewFeeRateBpsForProtocol({ - protocol, - preview, -}: { - protocol: PolymarketProtocolDefinition; - preview: OrderPreview; -}): string { - if (protocol.key === 'v2') { - return '0'; - } - - return preview.feeRateBps ?? '0'; +export function getPreviewFeeRateBpsForProtocol(): string { + return '0'; } export function encodeWrap({ @@ -362,17 +237,3 @@ export function encodeWrap({ 'function wrap(address _asset, address _to, uint256 _amount)', ]).encodeFunctionData('wrap', [asset, to, amount]) as Hex; } - -export function encodeUnwrap({ - asset, - to, - amount, -}: { - asset: string; - to: string; - amount: bigint | string; -}): Hex { - return new Interface([ - 'function unwrap(address _asset, address _to, uint256 _amount)', - ]).encodeFunctionData('unwrap', [asset, to, amount]) as Hex; -} diff --git a/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts index 8da8799707c..8ad8e0ad188 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts @@ -1,7 +1,7 @@ import type { ClobHeaders } from '../types'; import type { ProtocolRelayerOrder } from './orderCodec'; +import { POLYMARKET_V2_PROTOCOL } from './definitions'; import { submitProtocolClobOrder } from './transport'; -import { POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL } from './definitions'; jest.mock('../utils', () => ({ getPolymarketEndpoints: jest.fn(() => ({ @@ -34,30 +34,7 @@ describe('polymarket protocol transport', () => { jest.clearAllMocks(); }); - it('submits orders without the v2 routing header for v1', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ - success: true, - }), - }); - - await submitProtocolClobOrder({ - protocol: POLYMARKET_V1_PROTOCOL, - headers, - clobOrder, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - expect.objectContaining({ - headers: expect.not.objectContaining({ 'X-Clob-Version': '2' }), - }), - ); - }); - - it('adds the v2 routing header for v2', async () => { + it('adds the CLOB v2 routing header', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, diff --git a/app/components/UI/Predict/providers/polymarket/protocol/transport.ts b/app/components/UI/Predict/providers/polymarket/protocol/transport.ts index 4e8df360b47..f99ed436963 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/transport.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/transport.ts @@ -1,10 +1,7 @@ import type { Result } from '../../../types'; import type { ClobHeaders, OrderResponse } from '../types'; import { getPolymarketEndpoints } from '../utils'; -import type { - Permit2FeeAuthorization, - SafeFeeAuthorization, -} from '../safe/types'; +import type { Permit2FeeAuthorization } from '../safe/types'; import type { PolymarketProtocolDefinition } from './definitions'; import type { ProtocolRelayerOrder } from './orderCodec'; @@ -29,7 +26,7 @@ export async function submitProtocolClobOrder({ protocol: Pick; headers: ClobHeaders; clobOrder: ProtocolRelayerOrder; - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; + feeAuthorization?: Permit2FeeAuthorization; executor?: string; allowancesTx?: { to: string; data: string }; }): Promise> { @@ -37,9 +34,7 @@ export async function submitProtocolClobOrder({ const url = `${CLOB_RELAYER}/order`; const requestHeaders = normalizeRelayerHeaders(headers); - if (protocol.transport.clobVersionHeader) { - requestHeaders['X-Clob-Version'] = protocol.transport.clobVersionHeader; - } + requestHeaders['X-Clob-Version'] = protocol.transport.clobVersionHeader; const body = { ...clobOrder, diff --git a/app/components/UI/Predict/providers/polymarket/safe/constants.ts b/app/components/UI/Predict/providers/polymarket/safe/constants.ts index a7c62661622..d32e6483ad6 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/constants.ts @@ -1,5 +1,3 @@ -import { MATIC_CONTRACTS } from '../constants'; - export const SAFE_FACTORY_NAME = 'Polymarket Contract Proxy Factory'; export const SAFE_FACTORY_ADDRESS = @@ -17,19 +15,6 @@ export const DOMAIN_SEPARATOR_TYPEHASH = '0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218'; export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; -export const usdcSpenders = [ - MATIC_CONTRACTS.conditionalTokens, // Conditional Tokens Framework - MATIC_CONTRACTS.exchange, // CTF Exchange - MATIC_CONTRACTS.negRiskExchange, // Neg Risk CTF Exchange - MATIC_CONTRACTS.negRiskAdapter, -]; - -export const outcomeTokenSpenders = [ - MATIC_CONTRACTS.exchange, // CTF Exchange - MATIC_CONTRACTS.negRiskExchange, // Neg Risk Exchange - MATIC_CONTRACTS.negRiskAdapter, // Neg Risk Adapter -]; - export const MASTER_COPY_ADDRESS = '0xE51abdf814f8854941b9Fe8e3A4F65CAB4e7A4a8'; // Example Gnosis Safe mastercopy // You must use the SAME proxy creation code used in the factory diff --git a/app/components/UI/Predict/providers/polymarket/safe/types.ts b/app/components/UI/Predict/providers/polymarket/safe/types.ts index 60ff992776a..cfd50dc640f 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/types.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/types.ts @@ -16,14 +16,6 @@ export interface SplitSignature { v: string; } -export interface SafeFeeAuthorization { - type: 'safe-transaction'; - authorization: { - tx: SafeTransaction; // Safe transaction - sig: string; // Signature of the Safe transaction - }; -} - export interface Permit2FeeAuthorization { type: 'safe-permit2'; authorization: { diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts index 60945e53d64..25b661b5626 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts @@ -1,71 +1,15 @@ import { Interface } from 'ethers/lib/utils'; -import Engine from '../../../../../../core/Engine'; -import { - MATIC_CONTRACTS, - POLYGON_MAINNET_CHAIN_ID, - POLYMARKET_PROVIDER_ID, -} from '../constants'; -import { - PERMIT2_ADDRESS, - SAFE_FACTORY_ADDRESS, - SAFE_MULTISEND_ADDRESS, - usdcSpenders, -} from './constants'; +import { MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID } from '../constants'; +import { SAFE_FACTORY_ADDRESS, SAFE_MULTISEND_ADDRESS } from './constants'; import { + aggregateTransaction, computeProxyAddress, createPermit2FeeAuthorization, - createSafeFeeAuthorization, - getPermit2Nonce, getDeployProxyWalletTypedData, - encodeCreateProxy, - getDeployProxyWalletTransaction, - checkProxyWalletDeployed, - encodeMultisend, - createSafeMultisendTransaction, - aggregateTransaction, - createAllowancesSafeTransaction, - hasAllowances, - hasPermit2Allowance, - createClaimSafeTransaction, - getSafeTransactionCallData, - getProxyWalletAllowancesTransaction, - getClaimTransaction, - getWithdrawTransactionCallData, - getSafeUsdcAmount, - getSafeUsdcAmountRaw, + getSafeTransferAmount, + getSafeTransferAmountRaw, } from './utils'; -import { OperationType } from './types'; -import { Signer } from '../../types'; -import { numberToHex } from '@metamask/utils'; -import EthQuery from '@metamask/eth-query'; -import { query } from '@metamask/controller-utils'; -import { PredictPosition, PredictPositionStatus } from '../../../types'; -import { isSmartContractAddress } from '../../../../../../util/transactions'; -import { getAllowance, getIsApprovedForAll } from '../utils'; - -jest.mock('@metamask/transaction-controller', () => ({ - TransactionType: { - cancel: 'cancel', - contractInteraction: 'contractInteraction', - deployContract: 'deployContract', - incoming: 'incoming', - personalSign: 'personalSign', - retry: 'retry', - sign: 'sign', - signTypedData: 'signTypedData', - simpleSend: 'simpleSend', - smart: 'smart', - swap: 'swap', - swapAndSend: 'swapAndSend', - swapApproval: 'swapApproval', - tokenMethodApprove: 'tokenMethodApprove', - tokenMethodIncreaseAllowance: 'tokenMethodIncreaseAllowance', - tokenMethodSetApprovalForAll: 'tokenMethodSetApprovalForAll', - tokenMethodTransfer: 'tokenMethodTransfer', - tokenMethodTransferFrom: 'tokenMethodTransferFrom', - tokenMethodSafeTransferFrom: 'tokenMethodSafeTransferFrom', - }, -})); +import { OperationType, type SafeTransaction } from './types'; jest.mock('../../../../../../core/Engine', () => ({ context: { @@ -73,1518 +17,118 @@ jest.mock('../../../../../../core/Engine', () => ({ findNetworkClientIdByChainId: jest.fn(), getNetworkClientById: jest.fn(), }, - KeyringController: { - signPersonalMessage: jest.fn(), - }, }, })); -jest.mock('@metamask/controller-utils', () => ({ - query: jest.fn(), -})); - -jest.mock('@metamask/eth-query'); - -jest.mock('../../../../../../util/transactions', () => ({ - isSmartContractAddress: jest.fn(), -})); - -jest.mock('../utils', () => ({ - encodeApprove: jest.fn(() => '0x095ea7b3000000000000000000000000'), - encodeErc1155Approve: jest.fn(() => '0xa22cb465000000000000000000000000'), - encodeErc20Transfer: jest.fn(() => '0xa9059cbb000000000000000000000000'), - encodeClaim: jest.fn(() => '0x4e71d92d000000000000000000000000'), - getAllowance: jest.fn(), - getIsApprovedForAll: jest.fn(), - getContractConfig: jest.fn(() => ({ - conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', - negRiskAdapter: '0xC5d563A36AE78145C45a50134d48A1215220f80a', - })), -})); +const signer = { + address: '0x1111111111111111111111111111111111111111', + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), +}; -const mockFindNetworkClientIdByChainId = Engine.context.NetworkController - .findNetworkClientIdByChainId as jest.Mock; -const mockGetNetworkClientById = Engine.context.NetworkController - .getNetworkClientById as jest.Mock; -const mockSignPersonalMessage = Engine.context.KeyringController - .signPersonalMessage as jest.Mock; -const mockSignTypedMessage = jest.fn(); -const mockQuery = query as jest.Mock; -const mockIsSmartContractAddress = - isSmartContractAddress as jest.MockedFunction; -const mockGetAllowance = getAllowance as jest.MockedFunction< - typeof getAllowance ->; -const mockGetIsApprovedForAll = getIsApprovedForAll as jest.MockedFunction< - typeof getIsApprovedForAll ->; - -const TEST_ADDRESS = '0x1234567890123456789012345678901234567890' as const; -const TEST_SAFE_ADDRESS = '0x9999999999999999999999999999999999999999' as const; -const TEST_TO_ADDRESS = '0x100c7b833bbd604a77890783439bbb9d65e31de7' as const; - -function buildSigner({ - address = TEST_ADDRESS, - signPersonalMessage = mockSignPersonalMessage, - signTypedMessage = mockSignTypedMessage, -}: Partial = {}): Signer { - return { - address, - signPersonalMessage, - signTypedMessage, - }; -} - -function mockNetworkController() { - const mockProvider = {}; - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ - provider: mockProvider, - }); - return mockProvider; -} - -function setupMocksForFeeAuth() { - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); -} +const validSignature = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b`; describe('safe utils', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('computeProxyAddress', () => { - it('computes proxy address from signer address', () => { - const signer = buildSigner(); - - const proxyAddress = computeProxyAddress(signer.address); - - expect(proxyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); - expect(typeof proxyAddress).toBe('string'); - }); - - it('returns properly formatted address', () => { - const testAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - - const proxyAddress = computeProxyAddress(testAddress); - - expect(proxyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); - }); - - it('returns deterministic address for same input', () => { - const testAddress = '0x1234567890123456789012345678901234567890'; - - const proxyAddress1 = computeProxyAddress(testAddress); - const proxyAddress2 = computeProxyAddress(testAddress); - - expect(proxyAddress1).toBe(proxyAddress2); - }); - - it('returns different addresses for different inputs', () => { - const address1 = '0x1234567890123456789012345678901234567890'; - const address2 = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - - const proxyAddress1 = computeProxyAddress(address1); - const proxyAddress2 = computeProxyAddress(address2); - - expect(proxyAddress1).not.toBe(proxyAddress2); - }); - - it('computes address using CREATE2', () => { - const testAddress = '0x1234567890123456789012345678901234567890'; - - const proxyAddress = computeProxyAddress(testAddress); - - expect(proxyAddress).toBeTruthy(); - expect(proxyAddress.length).toBe(42); + signer.signPersonalMessage.mockResolvedValue(validSignature); + jest.spyOn(global.crypto, 'getRandomValues').mockImplementation((array) => { + if (array instanceof Uint32Array) { + array[0] = 7; + } + return array; }); }); - describe('createSafeFeeAuthorization', () => { - const testParams = { - signer: buildSigner(), - safeAddress: TEST_SAFE_ADDRESS, - amount: BigInt(1000000), - to: TEST_TO_ADDRESS, - }; - - it('creates fee authorization with correct structure', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - }); - - it('encodes ERC20 transfer correctly', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization({ - ...testParams, - amount: BigInt(500000), - }); - - const expectedTransferData = new Interface([ - 'function transfer(address to, uint256 amount)', - ]).encodeFunctionData('transfer', [TEST_TO_ADDRESS, BigInt(500000)]); - expect(feeAuth.authorization.tx.data).toBe(expectedTransferData); - }); - - it('sets operation type to Call', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization({ - ...testParams, - amount: BigInt(250000), - }); - - expect(feeAuth.authorization.tx.operation).toBe(OperationType.Call); - }); - - it('uses MATIC_CONTRACTS.collateral as token address', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.tx.to).toBe(MATIC_CONTRACTS.collateral); - }); - - it('signs the Safe transaction', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(mockSignPersonalMessage).toHaveBeenCalled(); - expect(feeAuth.authorization.sig).toBeTruthy(); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('returns SafeFeeAuthorization type', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.tx.value).toBe('0'); - expect(typeof feeAuth.authorization.sig).toBe('string'); - }); - - it('calls Safe contract for nonce', async () => { - setupMocksForFeeAuth(); - - await createSafeFeeAuthorization(testParams); - - expect(mockQuery).toHaveBeenCalledWith( - expect.any(EthQuery), - 'call', - expect.arrayContaining([ - expect.objectContaining({ - to: TEST_SAFE_ADDRESS, - }), - ]), - ); - }); - - it('handles undeployed Safe contract (nonce returns 0x)', async () => { - mockNetworkController(); - mockQuery - .mockResolvedValueOnce('0x') - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth.authorization.tx).toBeDefined(); - }); - - it('handles signature v value adjustment for 0 and 1', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.sig).toBeTruthy(); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value adjustment for 27 and 28', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901b', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.sig).toBeTruthy(); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('throws error for invalid signature v value', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899ff', - ); - - await expect(createSafeFeeAuthorization(testParams)).rejects.toThrow( - 'Invalid signature', - ); - }); - - it('handles signature v value 0 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value 1 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value 27 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901b', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value 28 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901c', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('getPermit2Nonce', () => { - it('returns a numeric string', async () => { - const nonce = await getPermit2Nonce(); - - expect(nonce).toMatch(/^\d+$/); - }); - - it('generates nonce from crypto.getRandomValues', async () => { - const spy = jest.spyOn(global.crypto, 'getRandomValues'); - - await getPermit2Nonce(); - - expect(spy).toHaveBeenCalledWith(expect.any(Uint32Array)); - spy.mockRestore(); - }); - }); - - describe('hasPermit2Allowance', () => { - it('returns true when Permit2 allowance is greater than zero', async () => { - mockGetAllowance.mockResolvedValueOnce(1n); - - const result = await hasPermit2Allowance({ address: TEST_SAFE_ADDRESS }); - - expect(result).toBe(true); - expect(mockGetAllowance).toHaveBeenCalledWith({ - tokenAddress: MATIC_CONTRACTS.collateral, - owner: TEST_SAFE_ADDRESS, - spender: PERMIT2_ADDRESS, - }); - }); - - it('returns false when Permit2 allowance is zero', async () => { - mockGetAllowance.mockResolvedValueOnce(0n); - - const result = await hasPermit2Allowance({ address: TEST_SAFE_ADDRESS }); - - expect(result).toBe(false); - }); - }); - - describe('createPermit2FeeAuthorization', () => { - it('creates safe-permit2 authorization payload', async () => { - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const authorization = await createPermit2FeeAuthorization({ - safeAddress: TEST_SAFE_ADDRESS, - signer: buildSigner(), - amount: 1_000_000n, - spender: TEST_TO_ADDRESS, - }); - - expect(authorization.type).toBe('safe-permit2'); - expect(authorization.authorization.permit.permitted.token).toBe( - MATIC_CONTRACTS.collateral, - ); - expect(authorization.authorization.permit.permitted.amount).toBe( - '1000000', - ); - expect(authorization.authorization.permit.nonce).toMatch(/^\d+$/); - expect(authorization.authorization.spender).toBe(TEST_TO_ADDRESS); - expect(authorization.authorization.signature).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('getDeployProxyWalletTypedData', () => { - it('returns correct typed data structure', async () => { - const typedData = await getDeployProxyWalletTypedData(); - - expect(typedData).toHaveProperty('domain'); - expect(typedData).toHaveProperty('types'); - expect(typedData).toHaveProperty('message'); - expect(typedData).toHaveProperty('primaryType', 'CreateProxy'); - }); - - it('uses correct domain values', async () => { - const typedData = await getDeployProxyWalletTypedData(); - - expect(typedData.domain.name).toBeDefined(); - expect(typedData.domain.chainId).toBe( - numberToHex(POLYGON_MAINNET_CHAIN_ID), - ); - expect(typedData.domain.verifyingContract).toBe(SAFE_FACTORY_ADDRESS); - }); - - it('includes CreateProxy type definition', async () => { - const typedData = await getDeployProxyWalletTypedData(); - - expect(typedData.types.CreateProxy).toBeDefined(); - expect(typedData.types.CreateProxy).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'paymentToken', type: 'address' }), - expect.objectContaining({ name: 'payment', type: 'uint256' }), - expect.objectContaining({ name: 'paymentReceiver', type: 'address' }), - ]), - ); - }); - }); - - describe('encodeCreateProxy', () => { - it('encodes createProxy function call', () => { - const result = encodeCreateProxy({ - paymentToken: '0x0000000000000000000000000000000000000000', - payment: '0', - paymentReceiver: '0x0000000000000000000000000000000000000000', - createSig: { - v: 27, - r: '0x' + 'a'.repeat(64), - s: '0x' + 'b'.repeat(64), - }, - }); - - expect(result).toMatch(/^0x[a-f0-9]+$/); - expect(typeof result).toBe('string'); - }); - }); - - describe('getDeployProxyWalletTransaction', () => { - it('returns transaction with correct structure', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const tx = await getDeployProxyWalletTransaction({ signer }); - - expect(tx).toHaveProperty('params'); - expect(tx?.params).toHaveProperty('to', SAFE_FACTORY_ADDRESS); - expect(tx?.params).toHaveProperty('data'); - expect(tx?.params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('calls signTypedMessage with correct parameters', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - await getDeployProxyWalletTransaction({ signer }); - - expect(mockSignTypedMessage).toHaveBeenCalled(); - }); - - it('throws error when signing fails', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockRejectedValue(new Error('Signature rejected')); - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => { - // Mock implementation to suppress console output - }); - - await expect(getDeployProxyWalletTransaction({ signer })).rejects.toThrow( - 'Failed to generate deploy proxy wallet transaction: Signature rejected', - ); - - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); - }); - - it('throws error with "Unknown error" when non-Error is thrown', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockRejectedValue('string error'); - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => { - // Mock implementation to suppress console output - }); - - await expect(getDeployProxyWalletTransaction({ signer })).rejects.toThrow( - 'Failed to generate deploy proxy wallet transaction: Unknown error', - ); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('checkProxyWalletDeployed', () => { - it('returns true when contract is deployed', async () => { - mockIsSmartContractAddress.mockResolvedValue(true); - - const isDeployed = await checkProxyWalletDeployed({ - address: TEST_SAFE_ADDRESS, - networkClientId: 'polygon', - }); - - expect(isDeployed).toBe(true); - expect(mockIsSmartContractAddress).toHaveBeenCalledWith( - TEST_SAFE_ADDRESS, - numberToHex(POLYGON_MAINNET_CHAIN_ID), - 'polygon', - ); - }); - - it('returns false when contract is not deployed', async () => { - mockIsSmartContractAddress.mockResolvedValue(false); - - const isDeployed = await checkProxyWalletDeployed({ - address: TEST_SAFE_ADDRESS, - networkClientId: 'polygon', - }); - - expect(isDeployed).toBe(false); - }); - }); - - describe('encodeMultisend', () => { - it('encodes single transaction', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - ]; - - const encoded = encodeMultisend({ txns }); - - expect(encoded).toMatch(/^0x[a-f0-9]+$/); - expect(typeof encoded).toBe('string'); - }); - - it('encodes multiple transactions', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - { - to: TEST_SAFE_ADDRESS, - value: '100', - data: '0xabcd', - operation: OperationType.DelegateCall, - }, - ]; - - const encoded = encodeMultisend({ txns }); - - expect(encoded).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('createSafeMultisendTransaction', () => { - it('creates multisend transaction with correct structure', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - ]; - - const multisendTx = createSafeMultisendTransaction(txns); - - expect(multisendTx.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(multisendTx.value).toBe('0'); - expect(multisendTx.operation).toBe(OperationType.DelegateCall); - expect(multisendTx.data).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('aggregateTransaction', () => { - it('returns single transaction when only one provided', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - ]; - - const result = aggregateTransaction(txns); - - expect(result).toEqual(txns[0]); - }); - - it('returns multisend transaction when multiple provided', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - { - to: TEST_SAFE_ADDRESS, - value: '100', - data: '0xabcd', - operation: OperationType.Call, - }, - ]; - - const result = aggregateTransaction(txns); - - expect(result.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(result.operation).toBe(OperationType.DelegateCall); - }); + afterEach(() => { + jest.restoreAllMocks(); }); - describe('createAllowancesSafeTransaction', () => { - it('creates transaction with approvals', () => { - const safeTxn = createAllowancesSafeTransaction(); - - expect(safeTxn).toHaveProperty('to'); - expect(safeTxn).toHaveProperty('value'); - expect(safeTxn).toHaveProperty('data'); - expect(safeTxn).toHaveProperty('operation'); - }); - - it('includes USDC approvals and outcome token approvals', () => { - const safeTxn = createAllowancesSafeTransaction(); - - expect(safeTxn.data).toBeDefined(); - expect(typeof safeTxn.data).toBe('string'); - }); - - it('includes extra USDC spenders when provided', () => { - const defaultSafeTxn = createAllowancesSafeTransaction(); - const safeTxnWithExtra = createAllowancesSafeTransaction({ - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - - expect(safeTxnWithExtra.data).toBeDefined(); - expect(safeTxnWithExtra.data.length).toBeGreaterThan( - defaultSafeTxn.data.length, - ); - }); + it('computes a deterministic proxy address', () => { + expect(computeProxyAddress(signer.address)).toMatch(/^0x[0-9a-fA-F]{40}$/u); + expect(computeProxyAddress(signer.address)).toBe( + computeProxyAddress(signer.address), + ); }); - describe('hasAllowances', () => { - it('returns true when all allowances are set', async () => { - mockGetAllowance.mockResolvedValue(100n); - mockGetIsApprovedForAll.mockResolvedValue(true); - - const result = await hasAllowances({ address: TEST_ADDRESS }); - - expect(result).toBe(true); - expect(mockGetAllowance).toHaveBeenCalled(); - expect(mockGetIsApprovedForAll).toHaveBeenCalledWith( - expect.objectContaining({ - tokenAddress: MATIC_CONTRACTS.conditionalTokens, - }), - ); - }); - - it('returns false when some allowances are zero', async () => { - mockGetAllowance.mockResolvedValueOnce(0n).mockResolvedValueOnce(100n); - mockGetIsApprovedForAll.mockResolvedValue(true); - - const result = await hasAllowances({ address: TEST_ADDRESS }); - - expect(result).toBe(false); - }); - - it('returns false when some approvals are not set', async () => { - mockGetAllowance.mockResolvedValue(100n); - mockGetIsApprovedForAll - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - const result = await hasAllowances({ address: TEST_ADDRESS }); - - expect(result).toBe(false); - }); - - it('checks allowances for extra USDC spenders', async () => { - mockGetAllowance.mockResolvedValue(100n); - mockGetIsApprovedForAll.mockResolvedValue(true); + it('builds deploy proxy typed data for Polygon', () => { + const typedData = getDeployProxyWalletTypedData(); - const result = await hasAllowances({ - address: TEST_ADDRESS, - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - - expect(result).toBe(true); - expect(mockGetAllowance).toHaveBeenCalledWith( - expect.objectContaining({ spender: PERMIT2_ADDRESS }), - ); - expect(mockGetAllowance).toHaveBeenCalledTimes(usdcSpenders.length + 1); + expect(typedData.domain).toEqual({ + name: 'Polymarket Contract Proxy Factory', + chainId: `0x${POLYGON_MAINNET_CHAIN_ID.toString(16)}`, + verifyingContract: SAFE_FACTORY_ADDRESS, }); + expect(typedData.primaryType).toBe('CreateProxy'); }); - describe('createClaimSafeTransaction', () => { - const mockPosition: PredictPosition = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'YES', - outcomeTokenId: 'token-1', - title: 'Test Market', - icon: 'icon.png', - amount: 100, - price: 0.5, - status: PredictPositionStatus.REDEEMABLE, - size: 100, - outcomeIndex: 0, - realizedPnl: 50, - percentPnl: 20, - cashPnl: 50, - initialValue: 100, - avgPrice: 0.5, - currentValue: 150, - endDate: '2025-01-01', - claimable: true, - }; - - it('creates claim transaction for single position', () => { - const safeTxn = createClaimSafeTransaction([mockPosition]); - - expect(safeTxn).toHaveProperty('to'); - expect(safeTxn).toHaveProperty('value'); - expect(safeTxn).toHaveProperty('data'); - expect(safeTxn).toHaveProperty('operation'); - }); - - it('creates claim transaction for multiple positions', () => { - const positions = [ - mockPosition, - { ...mockPosition, id: 'position-2', outcomeIndex: 1 }, - ]; - - const safeTxn = createClaimSafeTransaction(positions); - - expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(safeTxn.operation).toBe(OperationType.DelegateCall); - }); - - it('handles negRisk positions', () => { - const negRiskPosition = { ...mockPosition, negRisk: true }; - - const safeTxn = createClaimSafeTransaction([negRiskPosition]); - - expect(safeTxn.to).toBeDefined(); - expect(safeTxn.data).toBeDefined(); + it('creates pUSD Permit2 fee authorization by default', async () => { + const authorization = await createPermit2FeeAuthorization({ + safeAddress: '0x9999999999999999999999999999999999999999', + signer, + amount: 123n, + spender: '0x2222222222222222222222222222222222222222', }); - it('creates claim transaction without transfer when includeTransfer is not provided', () => { - const safeTxn = createClaimSafeTransaction([mockPosition]); - - expect(safeTxn).toHaveProperty('to'); - expect(safeTxn).toHaveProperty('data'); - }); - - it('includes transfer transaction when includeTransfer address is provided', () => { - const includeTransfer = { address: TEST_ADDRESS }; - - const safeTxn = createClaimSafeTransaction( - [mockPosition], - includeTransfer, - ); - - expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(safeTxn.operation).toBe(OperationType.DelegateCall); - expect(safeTxn.data).toBeDefined(); - }); - - it('creates multisend transaction with transfer for single position when includeTransfer is provided', () => { - const includeTransfer = { address: TEST_ADDRESS }; - - const safeTxn = createClaimSafeTransaction( - [mockPosition], - includeTransfer, - ); - - expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(safeTxn.operation).toBe(OperationType.DelegateCall); - }); - - it('includes transfer with correct recipient address', () => { - const recipientAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const includeTransfer = { address: recipientAddress }; - - const safeTxn = createClaimSafeTransaction( - [mockPosition], - includeTransfer, - ); - - expect(safeTxn).toBeDefined(); - expect(safeTxn.data).toBeDefined(); + expect(authorization.type).toBe('safe-permit2'); + expect(authorization.authorization.permit.permitted).toEqual({ + token: MATIC_CONTRACTS_V2.collateral, + amount: '123', }); + expect(authorization.authorization.spender).toBe( + '0x2222222222222222222222222222222222222222', + ); + expect(authorization.authorization.signature).toMatch(/^0x[0-9a-f]+$/u); }); - describe('getSafeTransactionCallData', () => { - it('generates call data for safe transaction execution', async () => { - // Given a Safe transaction and signer - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, - value: '0', + it('aggregates multiple transactions into a multisend delegatecall', () => { + const transactions: SafeTransaction[] = [ + { + to: '0x1111111111111111111111111111111111111111', data: '0x1234', operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); - - // When generating call data - const callData = await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - }); - - // Then call data is returned with correct format - expect(callData).toMatch(/^0x[a-f0-9]+$/); - expect(mockQuery).toHaveBeenCalled(); - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('handles overrides parameter', async () => { - // Given overrides are provided - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, value: '0', - data: '0x1234', - operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); - - const overrides = { gasLimit: '100000' }; - - // When generating call data with overrides - const callData = await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - overrides, - }); - - // Then call data is generated successfully - expect(callData).toMatch(/^0x[a-f0-9]+$/); - }); - - it('encodes execTransaction function call', async () => { - // Given a transaction to execute - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, - value: '100', - data: '0xabcdef', + }, + { + to: '0x2222222222222222222222222222222222222222', + data: '0xabcd', operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); - - // When generating call data - const callData = await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - }); - - // Then the call data contains execTransaction encoding - expect(callData).toBeDefined(); - expect(typeof callData).toBe('string'); - expect(callData.length).toBeGreaterThan(10); - }); - - it('queries nonce from Safe contract', async () => { - // Given a Safe address - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, value: '0', - data: '0x1234', - operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); + }, + ]; - // When generating call data - await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - }); - - // Then nonce is queried from contract - const nonceCall = mockQuery.mock.calls.find( - (call) => call[2][0].to === TEST_SAFE_ADDRESS, - ); - expect(nonceCall).toBeDefined(); - }); - }); - - describe('getProxyWalletAllowancesTransaction', () => { - it('generates transaction for setting allowances', async () => { - // Given a signer - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - const tx = await getProxyWalletAllowancesTransaction({ signer }); - - // Then transaction is returned with correct structure - expect(tx).toHaveProperty('params'); - expect(tx?.params).toHaveProperty('to'); - expect(tx?.params).toHaveProperty('data'); - expect(tx?.params.to).toMatch(/^0x[a-fA-F0-9]{40}$/); - expect(tx?.params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('uses computed proxy address for transaction', async () => { - // Given a signer with specific address - const signer = buildSigner({ - address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - }); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - const tx = await getProxyWalletAllowancesTransaction({ signer }); - - // Then transaction uses the computed proxy address - const expectedProxyAddress = computeProxyAddress(signer.address); - expect(tx?.params.to).toBe(expectedProxyAddress); - }); - - it('includes allowances for USDC and outcome tokens', async () => { - // Given a signer - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000009999999999999999999999999999999999999999', - ) - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - const tx = await getProxyWalletAllowancesTransaction({ signer }); - - // Then transaction data includes allowance settings - expect(tx?.params.data).toBeDefined(); - expect(tx?.params.data.length).toBeGreaterThan(10); - }); - - it('signs the transaction for execution', async () => { - // Given a signer - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000009999999999999999999999999999999999999999', - ) - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - await getProxyWalletAllowancesTransaction({ signer }); - - // Then signer's signPersonalMessage is called - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('throws error when signing fails', async () => { - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockRejectedValueOnce( - new Error('User rejected signing'), - ); - - await expect( - getProxyWalletAllowancesTransaction({ signer }), - ).rejects.toThrow( - 'Failed to generate proxy wallet allowances transaction: User rejected signing', - ); - }); + expect(aggregateTransaction(transactions)).toEqual( + expect.objectContaining({ + to: SAFE_MULTISEND_ADDRESS, + operation: OperationType.DelegateCall, + value: '0', + }), + ); }); - describe('getClaimTransaction', () => { - const mockPosition: PredictPosition = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'YES', - outcomeTokenId: 'token-1', - title: 'Test Market', - icon: 'icon.png', - amount: 100, - price: 0.5, - status: PredictPositionStatus.REDEEMABLE, - size: 100, - outcomeIndex: 0, - realizedPnl: 50, - percentPnl: 20, - cashPnl: 50, - initialValue: 100, - avgPrice: 0.5, - currentValue: 150, - endDate: '2025-01-01', - claimable: true, + it('keeps a single transaction unwrapped during aggregation', () => { + const transaction: SafeTransaction = { + to: '0x1111111111111111111111111111111111111111', + data: '0x1234', + operation: OperationType.Call, + value: '0', }; - it('generates claim transaction for positions', async () => { - // Given a signer and positions to claim - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then transaction is returned with correct structure - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - expect(txs[0].params).toHaveProperty('to', TEST_SAFE_ADDRESS); - expect(txs[0].params).toHaveProperty('data'); - expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles multiple positions in one transaction', async () => { - // Given multiple positions to claim - const signer = buildSigner(); - const positions = [ - mockPosition, - { ...mockPosition, id: 'position-2', outcomeIndex: 1 }, - ]; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then single transaction is returned with all claims - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - expect(txs[0].params).toHaveProperty('to'); - expect(txs[0].params).toHaveProperty('data'); - }); - - it('signs the claim transaction', async () => { - // Given a signer and positions - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - // When generating claim transaction - await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then signer's signPersonalMessage is called - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('uses provided Safe address', async () => { - // Given a specific Safe address - const signer = buildSigner(); - const positions = [mockPosition]; - const customSafeAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: customSafeAddress, - }); - - // Then transaction is sent to the provided Safe address - expect(txs[0].params.to).toBe(customSafeAddress); - }); - - it('creates transaction for negRisk positions', async () => { - // Given a negRisk position - const signer = buildSigner(); - const negRiskPosition = { ...mockPosition, negRisk: true }; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions: [negRiskPosition], - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then transaction is generated successfully - expect(txs).toBeDefined(); - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('generates claim transaction without transfer when includeTransferTransaction is false', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: false, - }); - - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - }); - - it('generates claim transaction without transfer when includeTransferTransaction is undefined', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - }); - - it('includes transfer transaction when includeTransferTransaction is true', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: true, - }); - - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - expect(txs[0].params).toHaveProperty('data'); - }); - - it('uses signer address for transfer when includeTransferTransaction is true', async () => { - const signerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const signer = buildSigner({ address: signerAddress }); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: true, - }); - - expect(txs).toBeDefined(); - expect(Array.isArray(txs)).toBe(true); - expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('signs claim transaction with transfer when includeTransferTransaction is true', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: true, - }); - - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - }); - - describe('getWithdrawTransactionCallData', () => { - it('generates call data for withdraw transaction', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(callData).toMatch(/^0x[a-f0-9]+$/); - expect(typeof callData).toBe('string'); - }); - - it('uses MATIC collateral contract address', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(callData).toBeDefined(); - expect(mockQuery).toHaveBeenCalled(); - }); - - it('creates Call operation type transaction', async () => { - const signer = buildSigner(); - const data = '0x1234567890abcdef'; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(callData).toBeTruthy(); - expect(callData.length).toBeGreaterThan(10); - }); - - it('signs the withdraw transaction', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('queries nonce from Safe contract', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - const nonceCall = mockQuery.mock.calls.find( - (call) => call[2][0].to === TEST_SAFE_ADDRESS, - ); - expect(nonceCall).toBeDefined(); - }); - - it('handles custom data parameter', async () => { - const signer = buildSigner(); - const customData = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de7000000000000000000000000000000000000000000000000000000000000007b'; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: customData as `0x${string}`, - }); - - expect(callData).toMatch(/^0x[a-f0-9]+$/); - }); + expect(aggregateTransaction([transaction])).toBe(transaction); }); - describe('getSafeUsdcAmountRaw', () => { - it('decodes the raw ERC20 amount without a float round-trip', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000186a00'; + it('decodes ERC20 transfer amounts from Safe editable calldata', () => { + const calldata = new Interface([ + 'function transfer(address to, uint256 value)', + ]).encodeFunctionData('transfer', [signer.address, 1_500_000]); - const amount = getSafeUsdcAmountRaw(data); - - expect(amount).toBe(1600000n); - }); + expect(getSafeTransferAmountRaw(calldata)).toBe(1_500_000n); + expect(getSafeTransferAmount(calldata)).toBe(1.5); }); - describe('getSafeUsdcAmount', () => { - it('decodes USDC amount from ERC20 transfer data', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000989680'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(10); - }); - - it('returns zero for transfer with zero amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000000000'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(0); - }); - - it('decodes small fractional USDC amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000000001'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(0.000001); - }); - - it('decodes large USDC amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000077359400'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(2000); - }); - - it('rounds to 6 decimal places for USDC precision', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000989680'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(10); - expect(amount.toString().split('.')[1]?.length || 0).toBeLessThanOrEqual( - 6, - ); - }); - - it('throws error for non-ERC20 transfer data', () => { - const invalidData = - '0x12345678000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000989680'; - - expect(() => getSafeUsdcAmount(invalidData)).toThrow( - 'Not an ERC20 transfer call', - ); - }); - - it('throws error for data without transfer selector', () => { - const invalidData = '0x000000000000000000000000100c7b833bbd604a77'; - - expect(() => getSafeUsdcAmount(invalidData)).toThrow( - 'Not an ERC20 transfer call', - ); - }); - - it('throws error for invalid encoded amount', () => { - const invalidData = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de7GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG'; - - expect(() => getSafeUsdcAmount(invalidData)).toThrow( - 'Invalid encoded amount in calldata', - ); - }); - - it('throws error for unreasonably large USDC amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; - - expect(() => getSafeUsdcAmount(data)).toThrow( - 'Decoded USDC amount is invalid or too large', - ); - }); - - it('handles 1.5 USDC amount correctly', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000186a00'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(1.6); - }); - - it('decodes medium-sized USDC amounts', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000002faf080'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(50); - }); - - it('validates non-negative amounts', () => { - const validData = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000000064'; - - const amount = getSafeUsdcAmount(validData); - - expect(amount).toBeGreaterThanOrEqual(0); - }); - - it('handles exact 1 USDC', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de700000000000000000000000000000000000000000000000000000000000f4240'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(1); - }); + it('rejects non-transfer calldata when decoding amounts', () => { + expect(() => getSafeTransferAmountRaw('0x12345678')).toThrow( + 'Not an ERC20 transfer call', + ); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.ts index 9fc9639a1d0..419353cd089 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.ts @@ -12,38 +12,23 @@ import { hexlify, Interface, keccak256, - parseUnits, solidityPack, splitSignature, } from 'ethers/lib/utils'; -import { PredictPosition } from '../../..'; import { PREDICT_CONSTANTS } from '../../../constants/errors'; import Engine from '../../../../../../core/Engine'; import Logger, { type LoggerErrorOptions } from '../../../../../../util/Logger'; import { isSmartContractAddress } from '../../../../../../util/transactions'; import { Signer } from '../../types'; import { - COLLATERAL_TOKEN_DECIMALS, - CONDITIONAL_TOKEN_DECIMALS, - MATIC_CONTRACTS, - MIN_COLLATERAL_BALANCE_FOR_CLAIM, + MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, } from '../constants'; -import { - encodeApprove, - encodeClaim, - encodeErc1155Approve, - encodeErc20Transfer, - getAllowance, - getContractConfig, - getIsApprovedForAll, -} from '../utils'; import { multisendAbi, safeAbi } from './abi'; import { DOMAIN_SEPARATOR_TYPEHASH, MASTER_COPY_ADDRESS, - outcomeTokenSpenders, PERMIT2_ADDRESS, PROXY_CREATION_CODE, SAFE_FACTORY_ADDRESS, @@ -51,18 +36,14 @@ import { SAFE_MSG_TYPEHASH, SAFE_MULTISEND_ADDRESS, SAFE_TX_TYPEHASH, - usdcSpenders, } from './constants'; import { OperationType, Permit2FeeAuthorization, - SafeFeeAuthorization, SafeTransaction, SplitSignature, } from './types'; -const MIN_VALID_HEX_DATA_LENGTH = 10; - function joinHexData(hexData: string[]): string { return `0x${hexData .map((hex) => { @@ -261,79 +242,12 @@ const getTransactionHash = ({ ) as Hex; }; -const signSafetransaction = async ( - safeAddress: Hex, - safeTx: SafeTransaction, - signer: Signer, -) => { - const nonce = await getNonce({ safeAddress }); - - const txHash = getTransactionHash({ - safeAddress, - to: safeTx.to, - value: safeTx.value, - data: safeTx.data, - operation: safeTx.operation, - nonce, - }); - - const rsvSignature = await signTransactionHash(signer, txHash); - const packedSig = abiEncodePacked( - { type: 'uint256', value: rsvSignature.r }, - { type: 'uint256', value: rsvSignature.s }, - { type: 'uint8', value: rsvSignature.v }, - ); - - return packedSig; -}; - -/** - * Creates a SafeFeeAuthorization for a given safe address, signer, amount, and to address - * @param safeAddress Safe address - * @param signer Signer - * @param amount Amount to transfer - * @param to payee address - * @returns SafeFeeAuthorization - */ -export const createSafeFeeAuthorization = async ({ - safeAddress, - signer, - amount, - to, -}: { - safeAddress: Hex; - signer: Signer; - amount: bigint; - to: Hex; -}): Promise => { - const erc20transfer = new Interface([ - 'function transfer(address to, uint256 amount)', - ]).encodeFunctionData('transfer', [to, amount]); - - const tx = { - to: MATIC_CONTRACTS.collateral, - operation: OperationType.Call, - data: erc20transfer, - value: '0', - }; - - const sig = await signSafetransaction(safeAddress, tx, signer); - - return { - type: 'safe-transaction', - authorization: { - tx, - sig, - }, - }; -}; - export const createPermit2FeeAuthorization = async ({ safeAddress, signer, amount, spender, - tokenAddress = MATIC_CONTRACTS.collateral, + tokenAddress = MATIC_CONTRACTS_V2.collateral, }: { safeAddress: Hex; signer: Signer; @@ -584,41 +498,6 @@ export const aggregateTransaction = ( return transaction; }; -export const createAllowancesSafeTransaction = (options?: { - extraUsdcSpenders?: string[]; -}) => { - const safeTxns: SafeTransaction[] = []; - const allUsdcSpenders = [ - ...usdcSpenders, - ...(options?.extraUsdcSpenders ?? []), - ]; - - for (const spender of allUsdcSpenders) { - safeTxns.push({ - to: MATIC_CONTRACTS.collateral, - data: encodeApprove({ - spender, - amount: ethers.constants.MaxUint256.toBigInt(), - }), - operation: OperationType.Call, - value: '0', - }); - } - - for (const spender of outcomeTokenSpenders) { - safeTxns.push({ - to: MATIC_CONTRACTS.conditionalTokens, - data: encodeErc1155Approve({ spender, approved: true }), - operation: OperationType.Call, - value: '0', - }); - } - - const safeTxn = aggregateTransaction(safeTxns); - - return safeTxn; -}; - export const getSafeTransactionCallData = async ({ signer, safeAddress, @@ -682,214 +561,6 @@ export const getSafeTransactionCallData = async ({ return callData; }; -export const getProxyWalletAllowancesTransaction = async ({ - signer, - extraUsdcSpenders, -}: { - signer: Signer; - extraUsdcSpenders?: string[]; -}) => { - try { - const safeAddress = computeProxyAddress(signer.address); - const safeTxn = createAllowancesSafeTransaction({ extraUsdcSpenders }); - const callData = await getSafeTransactionCallData({ - signer, - safeAddress, - txn: safeTxn, - }); - - if (!callData || callData.length < MIN_VALID_HEX_DATA_LENGTH) { - throw new Error( - `Invalid call data generated: ${callData?.length ?? 0} bytes, minimum ${MIN_VALID_HEX_DATA_LENGTH} required`, - ); - } - - return { - params: { - to: safeAddress as Hex, - data: callData as Hex, - }, - type: TransactionType.contractInteraction, - }; - } catch (error) { - const errorContext: LoggerErrorOptions = { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - provider: POLYMARKET_PROVIDER_ID, - }, - context: { - name: 'safeUtils', - data: { - method: 'getProxyWalletAllowancesTransaction', - }, - }, - }; - Logger.error(error as Error, errorContext); - - throw new Error( - `Failed to generate proxy wallet allowances transaction: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ); - } -}; - -export const hasAllowances = async ({ - address, - extraUsdcSpenders = [], -}: { - address: string; - extraUsdcSpenders?: string[]; -}) => { - const allowanceCalls = []; - const isApprovedForAllCalls = []; - const allUsdcSpenders = [...usdcSpenders, ...extraUsdcSpenders]; - for (const spender of allUsdcSpenders) { - allowanceCalls.push( - getAllowance({ - tokenAddress: MATIC_CONTRACTS.collateral, - owner: address, - spender, - }), - ); - } - for (const spender of outcomeTokenSpenders) { - isApprovedForAllCalls.push( - getIsApprovedForAll({ - tokenAddress: MATIC_CONTRACTS.conditionalTokens, - owner: address, - operator: spender, - }), - ); - } - const allowanceResults = await Promise.all(allowanceCalls); - const isApprovedForAllResults = await Promise.all(isApprovedForAllCalls); - return ( - allowanceResults.every((allowance) => allowance > 0) && - isApprovedForAllResults.every((isApproved) => isApproved) - ); -}; - -export const hasPermit2Allowance = async ({ - address, -}: { - address: string; -}): Promise => { - const allowance = await getAllowance({ - tokenAddress: MATIC_CONTRACTS.collateral, - owner: address, - spender: PERMIT2_ADDRESS, - }); - return allowance > 0; -}; - -export const createClaimSafeTransaction = ( - positions: PredictPosition[], - includeTransfer?: { - address: string; - }, -) => { - const safeTxns: SafeTransaction[] = []; - const contractConfig = getContractConfig(POLYGON_MAINNET_CHAIN_ID); - - for (const position of positions) { - const amounts: bigint[] = [0n, 0n]; - amounts[position.outcomeIndex] = BigInt( - parseUnits( - position.size.toString(), - CONDITIONAL_TOKEN_DECIMALS, - ).toString(), - ); - const negRisk = !!position.negRisk; - - const to = ( - negRisk ? contractConfig.negRiskAdapter : contractConfig.conditionalTokens - ) as Hex; - const callData = encodeClaim(position.outcomeId, negRisk, amounts); - safeTxns.push({ - to, - data: callData, - operation: OperationType.Call, - value: '0', - }); - } - - if (includeTransfer) { - safeTxns.push({ - to: MATIC_CONTRACTS.collateral, - data: encodeErc20Transfer({ - to: includeTransfer.address, - value: parseUnits( - MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), - COLLATERAL_TOKEN_DECIMALS, - ).toBigInt(), - }), - operation: OperationType.Call, - value: '0', - }); - } - - const safeTxn = aggregateTransaction(safeTxns); - - return safeTxn; -}; - -export const getClaimTransaction = async ({ - signer, - positions, - safeAddress, - includeTransferTransaction, -}: { - signer: Signer; - positions: PredictPosition[]; - safeAddress: string; - includeTransferTransaction?: boolean; -}) => { - const includeTransfer = includeTransferTransaction - ? { address: signer.address } - : undefined; - const safeTxn = createClaimSafeTransaction(positions, includeTransfer); - const callData = await getSafeTransactionCallData({ - signer, - safeAddress, - txn: safeTxn, - }); - return [ - { - params: { - to: safeAddress as Hex, - data: callData as Hex, - }, - type: TransactionType.predictClaim, - }, - ]; -}; - -export const getWithdrawTransactionCallData = async ({ - signer, - safeAddress, - data, -}: { - signer: Signer; - safeAddress: string; - data: Hex; -}) => { - const safeTxn: SafeTransaction = { - to: MATIC_CONTRACTS.collateral, - data, - operation: OperationType.Call, - value: '0', - }; - - const callData = await getSafeTransactionCallData({ - signer, - safeAddress, - txn: safeTxn, - }); - - return callData as Hex; -}; - /* * Computes the proxy address for a given user address * @param userAddress User address @@ -909,11 +580,11 @@ export function computeProxyAddress(userAddress: string): Hex { } /** - * Decodes USDC amount from ERC20 transfer calldata + * Decodes token amount from ERC20 transfer calldata. * @param data ERC20 transfer calldata (0xa9059cbb...) - * @returns USDC amount in decimal format (e.g., 1.5 for 1.5 USDC) + * @returns Raw token amount. */ -export function getSafeUsdcAmountRaw(data: string): bigint { +export function getSafeTransferAmountRaw(data: string): bigint { if (!data.startsWith('0xa9059cbb')) { throw new Error('Not an ERC20 transfer call'); } @@ -940,24 +611,28 @@ export function getSafeUsdcAmountRaw(data: string): bigint { } const rawAmount = amount.toBigInt(); - const maxReasonableRawAmount = parseUnits('100000000000', 6).toBigInt(); + const maxReasonableRawAmount = ethers.utils + .parseUnits('100000000000', 6) + .toBigInt(); if (rawAmount > maxReasonableRawAmount) { throw new Error( - `Decoded USDC amount is invalid or too large: ${rawAmount.toString()}`, + `Decoded token amount is invalid or too large: ${rawAmount.toString()}`, ); } if (rawAmount < 0n) { - throw new Error(`Decoded USDC amount is negative: ${rawAmount.toString()}`); + throw new Error( + `Decoded token amount is negative: ${rawAmount.toString()}`, + ); } return rawAmount; } -export function getSafeUsdcAmount(data: string): number { - const rawAmount = getSafeUsdcAmountRaw(data); - const usdcValue = parseFloat(ethers.utils.formatUnits(rawAmount, 6)); +export function getSafeTransferAmount(data: string): number { + const rawAmount = getSafeTransferAmountRaw(data); + const tokenValue = parseFloat(ethers.utils.formatUnits(rawAmount, 6)); - return Math.round(usdcValue * 1e6) / 1e6; + return Math.round(tokenValue * 1e6) / 1e6; } diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index 6b860a9f17f..a06b84a6379 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -1,5 +1,4 @@ import { PredictGamePeriod, Side } from '../../types'; -import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; export interface PolymarketPosition { conditionId: string; @@ -29,85 +28,6 @@ export enum UtilsSide { SELL, } -export interface OrderData { - /** - * Maker of the order, i.e the source of funds for the order - */ - maker: string; - - /** - * Address of the order taker. The zero address is used to indicate a public order - */ - taker: string; - - /** - * Token Id of the CTF ERC1155 asset to be bought or sold. - * If BUY, this is the tokenId of the asset to be bought, i.e the makerAssetId - * If SELL, this is the tokenId of the asset to be sold, i.e the takerAssetId - */ - tokenId: string; - - /** - * Maker amount, i.e the max amount of tokens to be sold - */ - makerAmount: string; - - /** - * Taker amount, i.e the minimum amount of tokens to be received - */ - takerAmount: string; - - /** - * The side of the order, BUY or SELL - */ - side: UtilsSide; - - /** - * Fee rate, in basis points, charged to the order maker, charged on proceeds - */ - feeRateBps: string; - - /** - * Nonce used for onchain cancellations - */ - nonce: string; - - /** - * Signer of the order. Optional, if it is not present the signer is the maker of the order. - */ - signer?: string; - - /** - * Timestamp after which the order is expired. - * Optional, if it is not present the value is '0' (no expiration) - */ - expiration?: string; - - /** - * Signature type used by the Order. Default value 'EOA' - */ - signatureType?: SignatureType; -} - -/** - * SignedOrder - * - * Based on the response from buildMarketOrderCreationArgs, which returns - * OrderData combined with a generated salt. A SignedOrder augments that - * structure with the EIP-712 signature string produced by the signer. - */ -export type SignedOrder = (OrderData & { salt: string }) & { - signature: string; -}; - -export interface ClobOrderObject { - order: Omit & { - side: Side; - salt: number; - }; - owner: string; - orderType: OrderType; -} // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type ClobHeaders = { POLY_ADDRESS: string; @@ -117,12 +37,6 @@ export type ClobHeaders = { POLY_PASSPHRASE: string; }; -export interface PolymarketOffchainTradeParams { - clobOrder: ClobOrderObject; - headers: ClobHeaders; - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; -} - // Polymarket API response types export interface PolymarketApiMarket { conditionId: string; @@ -334,12 +248,6 @@ export interface TickSizeResponse { minimum_tick_size: TickSize; } -export interface ClobOrderParams { - owner: string; - order: ClobOrderObject; - orderType: OrderType; -} - export interface OrderSummary { price: string; size: string; diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 1b33bc14948..158fd0f00ae 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -1,5565 +1,249 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { query } from '@metamask/controller-utils'; +import EthQuery from '@metamask/eth-query'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import Engine from '../../../../../core/Engine'; -import { - PredictCategory, - PredictMarketGame, - PredictOutcome, - PredictPositionStatus, - Side, - PredictActivityBuy, - PredictActivitySell, - PredictActivityEntry, -} from '../../types'; +import { Side } from '../../types'; import { PREDICT_ERROR_CODES } from '../../constants/errors'; -import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { - ClobAuthDomain, DEFAULT_CLOB_BASE_URL, - EIP712Domain, - HASH_ZERO_BYTES32, - LEGACY_V2_CLOB_BASE_URL, - MATIC_CONTRACTS, - MSG_TO_SIGN, + MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID, - POLYMARKET_PROVIDER_ID, } from './constants'; -import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; -import { - ApiKeyCreds, - ClobHeaders, - ClobOrderObject, - L2HeaderArgs, - OrderData, - OrderResponse, - OrderType, - PolymarketApiEvent, - PolymarketApiMarket, - PolymarketPosition, - SignatureType, - UtilsSide, -} from './types'; -import { GetMarketsParams } from '../types'; import { - buildOutcomeGroups, - buildPolyHmacSignature, - calculateFees, createApiKey, deriveApiKey, - encodeApprove, - encodeClaim, - encodeRedeemNegRiskPositions, - encodeRedeemPositions, - generateSalt, + getAllowance, getContractConfig, - getL1Headers, - getL2Headers, - getMarketsFromPolymarketApi, - getParsedMarketsFromPolymarketApi, + getIsApprovedForAll, getOrderBook, - getFeeRateBps, - getOrderTypedData, - getPolymarketEndpoints, - getPredictPositionStatus, - GROUP_ORDER, - parsePolymarketEvents, - parsePolymarketPositions, - parsePolymarketActivity, - priceValid, - SPORTS_MARKET_TYPE_TO_GROUP, - submitClobOrder, - decimalPlaces, - roundNormal, - roundDown, - roundUp, - roundOrderAmount, + getRawBalance, previewOrder, - getAllowanceCalls, - fetchCarouselFromPolymarketApi, - isSpreadMarket, - sortGameMarkets, - sortMarketsByField, - sortMarkets, - parsePolymarketMarket, - fetchChildEventsFromGammaApi, - mergeChildEventsIntoParent, } from './utils'; -// Mock external dependencies +const mockSignTypedMessage = jest.fn(); + +jest.mock('@metamask/controller-utils', () => ({ + query: jest.fn(), +})); + +jest.mock('@metamask/eth-query', () => + jest.fn().mockImplementation(() => ({})), +); + jest.mock('../../../../../core/Engine', () => ({ context: { KeyringController: { - signTypedMessage: jest.fn(), + signTypedMessage: (...args: unknown[]) => mockSignTypedMessage(...args), + }, + NetworkController: { + findNetworkClientIdByChainId: jest.fn(), + getNetworkClientById: jest.fn(), }, }, })); -jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => ({ - log: jest.fn(), -})); - -// Mock fetch globally const mockFetch = jest.fn(); global.fetch = mockFetch; - -// Mock crypto -Object.defineProperty(global, 'crypto', { - value: { - createHmac: jest.fn(), - } as any, - writable: true, -}); +const mockQuery = jest.mocked(query); +const mockEthQuery = jest.mocked(EthQuery); +const mockFindNetworkClientIdByChainId = jest.mocked( + Engine.context.NetworkController.findNetworkClientIdByChainId, +); +const mockGetNetworkClientById = jest.mocked( + Engine.context.NetworkController.getNetworkClientById, +); + +const apiKeyCreds = { + apiKey: 'api-key', + secret: 'secret', + passphrase: 'passphrase', +}; + +const orderBook = { + market: 'market-1', + asset_id: 'token-1', + hash: 'hash', + timestamp: new Date('2026-01-01T00:00:00.000Z').toISOString(), + asks: [{ price: '0.50', size: '100' }], + bids: [{ price: '0.49', size: '100' }], + min_order_size: '1', + tick_size: '0.01', + neg_risk: false, +}; describe('polymarket utils', () => { - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockApiKey: ApiKeyCreds = { - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }; - beforeEach(() => { jest.clearAllMocks(); - mockFetch.mockReset(); + mockSignTypedMessage.mockResolvedValue('0xsig'); + mockFindNetworkClientIdByChainId.mockReturnValue('test-network-client-id'); + mockGetNetworkClientById.mockReturnValue({ + provider: {}, + } as ReturnType< + typeof Engine.context.NetworkController.getNetworkClientById + >); + }); - // Setup default fetch mock to prevent unhandled rejections + it('creates API keys against the canonical CLOB host', async () => { mockFetch.mockResolvedValue({ + status: 200, ok: true, - json: jest.fn().mockResolvedValue({}), - } as any); - - // Setup default mock implementations - ( - Engine.context.KeyringController.signTypedMessage as jest.Mock - ).mockResolvedValue('mock-signature'); - (global.crypto as any).createHmac.mockReturnValue({ - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('mock-digest-base64'), - }); - }); - - describe('getPolymarketEndpoints', () => { - it('return production endpoints', () => { - const endpoints = getPolymarketEndpoints(); - expect(endpoints).toEqual({ - GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', - CLOB_ENDPOINT: DEFAULT_CLOB_BASE_URL, - CRYPTO_PRICE_ENDPOINT: 'https://polymarket.com/api/crypto/crypto-price', - DATA_API_ENDPOINT: 'https://data-api.polymarket.com', - GEOBLOCK_API_ENDPOINT: 'https://polymarket.com/api/geoblock', - HOMEPAGE_CAROUSEL_ENDPOINT: - 'https://polymarket.com/api/homepage/carousel', - CLOB_RELAYER: 'https://predict.api.cx.metamask.io', - }); - }); - }); - - describe('getL1Headers', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('generate correct L1 headers', async () => { - const expectedHeaders = { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'mock-signature', - POLY_TIMESTAMP: '1704067200', - POLY_NONCE: '0', - }; - - const headers = await getL1Headers({ address: mockAddress }); - - expect(headers).toEqual(expectedHeaders); - expect( - Engine.context.KeyringController.signTypedMessage, - ).toHaveBeenCalledWith( - { - data: { - domain: { - name: 'ClobAuthDomain', - version: '1', - chainId: POLYGON_MAINNET_CHAIN_ID, - }, - types: { - EIP712Domain, - ...ClobAuthDomain, - }, - message: { - address: mockAddress, - timestamp: '1704067200', - nonce: 0, - message: MSG_TO_SIGN, - }, - primaryType: 'ClobAuth', - }, - from: mockAddress, - }, - SignTypedDataVersion.V4, - ); - }); - - it('handle signing errors', async () => { - const error = new Error('Signing failed'); - ( - Engine.context.KeyringController.signTypedMessage as jest.Mock - ).mockRejectedValue(error); - - await expect(getL1Headers({ address: mockAddress })).rejects.toThrow( - 'Signing failed', - ); - }); - }); - - describe('buildPolyHmacSignature', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('build HMAC signature without body', async () => { - const secret = 'test-secret'; - const timestamp = 1704067200; - const method = 'GET'; - const requestPath = '/test'; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - const signature = await buildPolyHmacSignature( - secret, - timestamp, - method, - requestPath, - ); - - expect((global.crypto as any).createHmac).toHaveBeenCalledWith( - 'sha256', - Buffer.from(secret, 'base64'), - ); - expect(mockHmac.update).toHaveBeenCalledWith('1704067200GET/test'); - expect(mockHmac.digest).toHaveBeenCalledWith('base64'); - expect(signature).toBe('test-signature_'); // + -> -, / -> _ - }); - - it('build HMAC signature with body', async () => { - const secret = 'test-secret'; - const timestamp = 1704067200; - const method = 'POST'; - const requestPath = '/test'; - const body = '{"test": "data"}'; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - const signature = await buildPolyHmacSignature( - secret, - timestamp, - method, - requestPath, - body, - ); - - expect(mockHmac.update).toHaveBeenCalledWith( - '1704067200POST/test{"test": "data"}', - ); - expect(signature).toBe('test-signature_'); - }); - - it('handle empty secret', async () => { - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - await buildPolyHmacSignature('', 1704067200, 'GET', '/test'); - - expect((global.crypto as any).createHmac).toHaveBeenCalledWith( - 'sha256', - Buffer.from('', 'base64'), - ); - }); - }); - - describe('getL2Headers', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('generate correct L2 headers', async () => { - const l2HeaderArgs: L2HeaderArgs = { - method: 'POST', - requestPath: '/order', - body: '{"test": "data"}', - }; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - const headers = await getL2Headers({ - l2HeaderArgs, - address: mockAddress, - apiKey: mockApiKey, - }); - - expect(headers).toEqual({ - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - }); - }); - - it('use provided timestamp', async () => { - const l2HeaderArgs: L2HeaderArgs = { - method: 'GET', - requestPath: '/markets', - }; - const customTimestamp = 1704067300; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - await getL2Headers({ - l2HeaderArgs, - timestamp: customTimestamp, - address: mockAddress, - apiKey: mockApiKey, - }); - - expect(mockHmac.update).toHaveBeenCalledWith( - `${customTimestamp}GET/markets`, - ); + json: jest.fn().mockResolvedValue(apiKeyCreds), }); - it('handle undefined apiKey gracefully', async () => { - const l2HeaderArgs: L2HeaderArgs = { - method: 'GET', - requestPath: '/markets', - }; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); + await expect( + createApiKey({ address: '0x1111111111111111111111111111111111111111' }), + ).resolves.toEqual(apiKeyCreds); - await getL2Headers({ - l2HeaderArgs, - address: mockAddress, - apiKey: undefined as any, - }); - - expect(mockHmac.update).toHaveBeenCalledWith('1704067200GET/markets'); - expect(mockHmac.digest).toHaveBeenCalledWith('base64'); - }); + expect(mockSignTypedMessage).toHaveBeenCalledWith( + expect.any(Object), + SignTypedDataVersion.V4, + ); + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_CLOB_BASE_URL}/auth/api-key`, + expect.objectContaining({ method: 'POST' }), + ); }); - describe('deriveApiKey', () => { - it('derive API key successfully', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - mockFetch.mockResolvedValue(mockResponse); - - const result = await deriveApiKey({ address: mockAddress }); - - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/auth/derive-api-key', - { - method: 'GET', - headers: expect.objectContaining({ - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'mock-signature', - }), - }, - ); - }); - - it('defaults v2 API key derivation to the canonical CLOB endpoint', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - mockFetch.mockResolvedValue(mockResponse); - - await deriveApiKey({ address: mockAddress, clobVersion: 'v2' }); - - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/auth/derive-api-key`, - expect.objectContaining({ - method: 'GET', - }), - ); - }); - - it('uses the temporary v2 CLOB host override when provided', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - mockFetch.mockResolvedValue(mockResponse); - - await deriveApiKey({ - address: mockAddress, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/auth/derive-api-key`, - expect.objectContaining({ - method: 'GET', - }), - ); + it('derives API keys against the canonical CLOB host', async () => { + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + json: jest.fn().mockResolvedValue(apiKeyCreds), }); - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); + await expect( + deriveApiKey({ address: '0x1111111111111111111111111111111111111111' }), + ).resolves.toEqual(apiKeyCreds); - await expect(deriveApiKey({ address: mockAddress })).rejects.toThrow( - 'Network error', - ); - }); + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_CLOB_BASE_URL}/auth/derive-api-key`, + expect.objectContaining({ method: 'GET' }), + ); }); - describe('createApiKey', () => { - it('create API key successfully', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - status: 200, - }; - mockFetch.mockResolvedValue(mockResponse); - - const result = await createApiKey({ address: mockAddress }); - - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/auth/api-key', - { - method: 'POST', - headers: expect.objectContaining({ - POLY_ADDRESS: mockAddress, - }), - body: '', - }, - ); - }); - - it('defaults v2 API key creation to the canonical CLOB endpoint', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - status: 200, - }; - mockFetch.mockResolvedValue(mockResponse); - - await createApiKey({ address: mockAddress, clobVersion: 'v2' }); - - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/auth/api-key`, - expect.objectContaining({ - method: 'POST', - body: '', - }), - ); - }); - - it('uses the temporary v2 CLOB host override for API key creation when provided', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - status: 200, - }; - mockFetch.mockResolvedValue(mockResponse); - - await createApiKey({ - address: mockAddress, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/auth/api-key`, - expect.objectContaining({ - method: 'POST', - body: '', - }), - ); - }); - - it('derive API key when creation returns 400', async () => { - const createResponse = { - ok: false, - json: jest.fn().mockResolvedValue({}), + it('falls back to deriving an API key when creation returns 400', async () => { + mockFetch + .mockResolvedValueOnce({ status: 400, - }; - const deriveResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - - mockFetch - .mockResolvedValueOnce(createResponse) - .mockResolvedValueOnce(deriveResponse); - - const result = await createApiKey({ address: mockAddress }); - - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('derives from the provided v2 CLOB host when v2 creation returns 400', async () => { - const createResponse = { ok: false, - json: jest.fn().mockResolvedValue({}), - status: 400, - }; - const deriveResponse = { + json: jest.fn(), + }) + .mockResolvedValueOnce({ + status: 200, ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - - mockFetch - .mockResolvedValueOnce(createResponse) - .mockResolvedValueOnce(deriveResponse); - - const result = await createApiKey({ - address: mockAddress, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, + json: jest.fn().mockResolvedValue(apiKeyCreds), }); - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - `${LEGACY_V2_CLOB_BASE_URL}/auth/api-key`, - expect.objectContaining({ method: 'POST' }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - `${LEGACY_V2_CLOB_BASE_URL}/auth/derive-api-key`, - expect.objectContaining({ method: 'GET' }), - ); - }); - - it('handle creation errors', async () => { - const error = new Error('Creation failed'); - mockFetch.mockRejectedValue(error); + await expect( + createApiKey({ address: '0x1111111111111111111111111111111111111111' }), + ).resolves.toEqual(apiKeyCreds); - await expect(createApiKey({ address: mockAddress })).rejects.toThrow( - 'Creation failed', - ); - }); + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + `${DEFAULT_CLOB_BASE_URL}/auth/api-key`, + expect.objectContaining({ method: 'POST' }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + `${DEFAULT_CLOB_BASE_URL}/auth/derive-api-key`, + expect.objectContaining({ method: 'GET' }), + ); }); - describe('priceValid', () => { - it('return true for valid prices', () => { - expect(priceValid(0.5, '0.1')).toBe(true); - expect(priceValid(0.6, '0.01')).toBe(true); - expect(priceValid(0.05, '0.001')).toBe(true); - expect(priceValid(0.9, '0.1')).toBe(true); // Upper bound for tickSize 0.1 + it('fetches order books from the canonical CLOB host', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(orderBook), }); - it('return false for invalid prices', () => { - expect(priceValid(0.05, '0.1')).toBe(false); // Below minimum tick - expect(priceValid(0.95, '0.1')).toBe(false); // Above 1 - minimum tick (0.9) - expect(priceValid(1.5, '0.1')).toBe(false); // Above 1 - expect(priceValid(-0.1, '0.1')).toBe(false); // Negative - }); + await expect(getOrderBook({ tokenId: 'token-1' })).resolves.toEqual( + orderBook, + ); - it.each([ - ['0.1', 0.6], - ['0.01', 0.55], - ['0.001', 0.544], - ['0.0001', 0.5444], - ] as const)( - 'should validate tick size %s correctly', - (tickSize, validPrice) => { - expect(priceValid(validPrice, tickSize)).toBe(true); - expect(priceValid(parseFloat(tickSize) - 0.001, tickSize)).toBe(false); // Well below minimum - expect(priceValid(1 - parseFloat(tickSize) + 0.001, tickSize)).toBe( - false, - ); // Well above maximum - }, + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_CLOB_BASE_URL}/book?token_id=token-1`, + { method: 'GET' }, ); }); - describe('getOrderBook', () => { - it('fetch order book successfully', async () => { - const mockOrderBook = { - bids: [ - { price: '0.4', size: '100' }, - { price: '0.45', size: '200' }, - ], - asks: [ - { price: '0.6', size: '150' }, - { price: '0.55', size: '100' }, - ], - }; - - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockOrderBook), - }; - mockFetch.mockResolvedValue(mockResponse); - - const result = await getOrderBook({ tokenId: 'test-token' }); - - expect(result).toEqual(mockOrderBook); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/book?token_id=test-token', - { method: 'GET' }, - ); - }); - - it('defaults the v2 order book to the canonical CLOB endpoint', async () => { - const mockOrderBook = { - bids: [], - asks: [], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockOrderBook), - }; - mockFetch.mockResolvedValue(mockResponse); - - await getOrderBook({ tokenId: 'test-token', clobVersion: 'v2' }); - - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/book?token_id=test-token`, - { method: 'GET' }, - ); - }); - - it('uses the temporary v2 CLOB host override for order book reads when provided', async () => { - const mockOrderBook = { - bids: [], - asks: [], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockOrderBook), - }; - mockFetch.mockResolvedValue(mockResponse); - - await getOrderBook({ - tokenId: 'test-token', - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/book?token_id=test-token`, - { method: 'GET' }, - ); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - await expect(getOrderBook({ tokenId: 'test-token' })).rejects.toThrow( - 'Network error', - ); - }); - - it('throws PREVIEW_NO_ORDER_BOOK error when orderbook does not exist', async () => { - const mockResponse = { - ok: false, - json: jest.fn().mockResolvedValue({ - error: 'No orderbook exists for the requested token id', - }), - }; - mockFetch.mockResolvedValue(mockResponse); - - await expect(getOrderBook({ tokenId: 'test-token' })).rejects.toThrow( - PREDICT_ERROR_CODES.PREVIEW_NO_ORDER_BOOK, - ); + it('maps missing order book errors to the Predict preview error code', async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: jest.fn().mockResolvedValue({ + error: 'No orderbook exists for the requested token id', + }), }); - it('throws error message from response when response is not ok', async () => { - const mockResponse = { - ok: false, - json: jest.fn().mockResolvedValue({ error: 'Custom error message' }), - }; - mockFetch.mockResolvedValue(mockResponse); - - await expect(getOrderBook({ tokenId: 'test-token' })).rejects.toThrow( - 'Custom error message', - ); - }); + await expect(getOrderBook({ tokenId: 'token-1' })).rejects.toThrow( + PREDICT_ERROR_CODES.PREVIEW_NO_ORDER_BOOK, + ); }); - describe('getFeeRateBps', () => { - it('returns fee rate from CLOB fee-rate endpoint', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ base_fee: 30 }), - }); - - const result = await getFeeRateBps({ tokenId: 'test-token' }); - - expect(result).toBe('30'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/fee-rate?token_id=test-token', - { method: 'GET' }, - ); - }); - - it('returns zero fee rate when fee-rate endpoint responds with error', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: jest - .fn() - .mockResolvedValue({ error: 'fee rate not found for market' }), - }); - - const result = await getFeeRateBps({ tokenId: 'test-token' }); - - expect(result).toBe('0'); - }); - - it('returns zero fee rate when fee-rate endpoint throws', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - const result = await getFeeRateBps({ tokenId: 'test-token' }); - - expect(result).toBe('0'); + it('previews orders using CLOB v2 order books and zero fee-rate bps', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(orderBook), }); - }); - describe('generateSalt', () => { - it('generate a valid hex salt', () => { - const salt = generateSalt(); - expect(typeof salt).toBe('string'); - expect(salt.startsWith('0x')).toBe(true); - expect(salt.length).toBeGreaterThan(2); - // Should be a valid hex number - expect(() => parseInt(salt.slice(2), 16)).not.toThrow(); + const preview = await previewOrder({ + marketId: 'market-1', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 10, }); - it('generate different salts on multiple calls', () => { - const salt1 = generateSalt(); - const salt2 = generateSalt(); - expect(salt1).not.toBe(salt2); - }); + expect(preview).toEqual( + expect.objectContaining({ + marketId: 'market-1', + outcomeTokenId: 'token-1', + feeRateBps: '0', + negRisk: false, + }), + ); }); - describe('getContractConfig', () => { - it('return Polygon mainnet contracts', () => { - const config = getContractConfig(POLYGON_MAINNET_CHAIN_ID); - expect(config).toEqual(MATIC_CONTRACTS); - }); - - it('throw error for unsupported chain', () => { - expect(() => getContractConfig(999)).toThrow( - 'MetaMask Predict is only supported on Polygon mainnet', - ); - }); + it('returns the v2 contract config for Polygon', () => { + expect(getContractConfig(POLYGON_MAINNET_CHAIN_ID)).toBe( + MATIC_CONTRACTS_V2, + ); }); - describe('getOrderTypedData', () => { - const orderData: OrderData & { salt: string } = { - salt: '12345', - maker: mockAddress, - signer: mockAddress, - taker: '0x0000000000000000000000000000000000000000', - tokenId: 'test-token', - makerAmount: '100000000', - takerAmount: '50000000', - expiration: '0', - nonce: '0', - feeRateBps: '0', - side: UtilsSide.BUY, - signatureType: SignatureType.EOA, - }; + it('treats empty balance results as zero', async () => { + mockQuery.mockResolvedValue('0x'); - it('generate correct typed data structure', () => { - const result = getOrderTypedData({ - order: orderData, - chainId: POLYGON_MAINNET_CHAIN_ID, - verifyingContract: '0x1234567890123456789012345678901234567890', - }); + await expect( + getRawBalance({ + address: '0x1111111111111111111111111111111111111111', + tokenAddress: '0x2222222222222222222222222222222222222222', + }), + ).resolves.toBe(0n); - expect(result.primaryType).toBe('Order'); - expect(result.domain).toEqual({ - name: 'Polymarket CTF Exchange', - version: '1', - chainId: POLYGON_MAINNET_CHAIN_ID, - verifyingContract: '0x1234567890123456789012345678901234567890', - }); - expect(result.types).toEqual({ - EIP712Domain: [ - ...EIP712Domain, - { name: 'verifyingContract', type: 'address' }, - ], - Order: [ - { name: 'salt', type: 'uint256' }, - { name: 'maker', type: 'address' }, - { name: 'signer', type: 'address' }, - { name: 'taker', type: 'address' }, - { name: 'tokenId', type: 'uint256' }, - { name: 'makerAmount', type: 'uint256' }, - { name: 'takerAmount', type: 'uint256' }, - { name: 'expiration', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'feeRateBps', type: 'uint256' }, - { name: 'side', type: 'uint8' }, - { name: 'signatureType', type: 'uint8' }, - ], - }); - expect(result.message).toEqual(orderData); - }); + expect(mockEthQuery).toHaveBeenCalled(); }); - describe('encodeApprove', () => { - it('encode approve function call correctly', () => { - const spender = '0x1234567890123456789012345678901234567890'; - const amount = BigInt(1000000); + it('treats empty allowance results as zero', async () => { + mockQuery.mockResolvedValue('0x'); - const result = encodeApprove({ spender, amount }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('handle string amounts', () => { - const spender = '0x1234567890123456789012345678901234567890'; - const amount = '1000000'; - - const result = encodeApprove({ spender, amount }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); + await expect( + getAllowance({ + tokenAddress: '0x2222222222222222222222222222222222222222', + owner: '0x1111111111111111111111111111111111111111', + spender: '0x3333333333333333333333333333333333333333', + }), + ).resolves.toBe(0n); }); - describe('submitClobOrder', () => { - const mockHeaders: ClobHeaders = { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - }; - - const mockClobOrder: ClobOrderObject = { - order: { - maker: mockAddress, - signer: mockAddress, - taker: '0x0000000000000000000000000000000000000000', - tokenId: 'test-token', - makerAmount: '100000000', - takerAmount: '50000000', - expiration: '0', - nonce: '0', - feeRateBps: '0', - side: Side.BUY, - signatureType: SignatureType.EOA, - signature: 'mock-signature', - salt: 12345, - }, - owner: mockAddress, - orderType: OrderType.FOK, - }; - - const mockOrderResponse: OrderResponse = { - errorMsg: '', - makingAmount: '100000000', - orderID: 'order-123', - status: 'success', - success: true, - takingAmount: '50000000', - transactionsHashes: [], - }; - - beforeEach(() => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockOrderResponse), - }); - }); - - it('submit CLOB order successfully', async () => { - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: true, - response: mockOrderResponse, - }); - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...mockClobOrder, - feeAuthorization: undefined, - }), - }, - ); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Failed to submit CLOB order: Network error', - }); - }); - - it('includes feeAuthorization in request body when provided', async () => { - const feeAuthorization = { - type: 'safe-transaction' as const, - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - feeAuthorization, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ ...mockClobOrder, feeAuthorization }), - }, - ); - }); - - it('omits feeAuthorization when undefined', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...mockClobOrder, - feeAuthorization: undefined, - }), - }, - ); - }); - - it('serializes feeAuthorization correctly to JSON', async () => { - const feeAuthorization = { - type: 'safe-transaction' as const, - authorization: { - tx: { - to: '0x1234567890123456789012345678901234567890', - operation: 0, - data: '0xabcdef', - value: '100', - }, - sig: '0xdeadbeef', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - feeAuthorization, - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody).toHaveProperty('feeAuthorization'); - expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); - }); - - it('uses CLOB_RELAYER endpoint when feeAuthorization is not provided for BUY orders', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...mockClobOrder, - feeAuthorization: undefined, - }), - }, - ); - }); - - it('uses CLOB_RELAYER endpoint for SELL orders with feeAuthorization', async () => { - const sellClobOrder: ClobOrderObject = { - ...mockClobOrder, - order: { - ...mockClobOrder.order, - side: Side.SELL, - }, - }; - - const feeAuthorization = { - type: 'safe-transaction' as const, - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: sellClobOrder, - feeAuthorization, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...sellClobOrder, - feeAuthorization, - }), - }, - ); - }); - - it('includes executor in request body when provided', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - executor: '0x1111111111111111111111111111111111111111', - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody.executor).toBe( - '0x1111111111111111111111111111111111111111', - ); - }); - - it('supports Permit2 fee authorization payload in request body', async () => { - const feeAuthorization = { - type: 'safe-permit2' as const, - authorization: { - permit: { - permitted: { - token: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - amount: '1000000', - }, - nonce: '0', - deadline: '1700000000', - }, - spender: '0x1111111111111111111111111111111111111111', - signature: '0xabc', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - feeAuthorization, - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); - }); - - it('includes allowancesTx in request body when provided', async () => { - const allowancesTx = { to: '0xSafeAddress', data: '0xallowanceData' }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - allowancesTx, - }); + it('treats empty approval results as false', async () => { + mockQuery.mockResolvedValue('0x'); - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody.allowancesTx).toEqual(allowancesTx); - }); - - it('omits allowancesTx from request body when not provided', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody).not.toHaveProperty('allowancesTx'); - }); - }); - - describe('parsePolymarketEvents', () => { - const mockCategory: PredictCategory = 'trending'; - - const mockEvent: PolymarketApiEvent = { - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [{ id: '1', slug: 'test', title: 'Test', recurrence: 'daily' }], - markets: [ - { - conditionId: 'market-1', - question: 'Will it rain?', - // Event description matches markets' descriptions (as per Polymarket's team) - description: 'A test event', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Weather', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 1000000, - volume: 1000000, - }; - - it('parse events correctly', () => { - const result = parsePolymarketEvents([mockEvent], mockCategory); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - id: 'event-1', - slug: 'test-event', - providerId: POLYMARKET_PROVIDER_ID, - title: 'Test Event', - description: 'A test event', - image: 'https://example.com/icon.png', - status: 'open', - recurrence: 'daily', - series: { - id: '1', - slug: 'test', - title: 'Test', - recurrence: 'daily', - }, - endDate: undefined, - game: undefined, - category: mockCategory, - tags: [], - outcomes: [ - { - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - title: 'Will it rain?', - description: 'A test event', - image: 'https://example.com/market-icon.png', - groupItemTitle: 'Weather', - groupItemThreshold: undefined, - status: 'open', - volume: 1000, - liquidity: 500, - resolutionStatus: 'unresolved', - tokens: [ - { - id: 'token-1', - title: 'Yes', - price: 0.6, - }, - { - id: 'token-2', - title: 'No', - price: 0.4, - }, - ], - sportsMarketType: undefined, - negRisk: true, - tickSize: '0.01', - resolvedBy: '0x0000000000000000000000000000000000000000', - }, - ], - liquidity: 1000000, - volume: 1000000, - }); - }); - - it('handle closed events', () => { - const closedEvent = { - ...mockEvent, - closed: true, - markets: [ - { - ...mockEvent.markets[0], - closed: true, - }, - ], - }; - const result = parsePolymarketEvents([closedEvent], mockCategory); - - expect(result[0].status).toBe('closed'); - expect(result[0].outcomes[0].status).toBe('closed'); - }); - - it('handle null clobTokenIds', () => { - const eventWithNullTokens = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - clobTokenIds: '[]', - outcomes: '[]', - outcomePrices: '[]', - }, - ], - }; - - const result = parsePolymarketEvents([eventWithNullTokens], mockCategory); - - expect(result[0].outcomes[0].tokens).toEqual([]); - }); - - it('use market image when icon is not available', () => { - const eventWithoutIcon = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - icon: '', - }, - ], - }; - - const result = parsePolymarketEvents([eventWithoutIcon], mockCategory); - - expect(result[0].outcomes[0].image).toBe(''); - }); - - it('filter out inactive markets', () => { - const eventWithInactiveMarkets = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-1', - active: true, - }, - { - ...mockEvent.markets[0], - conditionId: 'market-2', - active: false, - }, - { - ...mockEvent.markets[0], - conditionId: 'market-3', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithInactiveMarkets], - mockCategory, - ); - - expect(result[0].outcomes).toHaveLength(2); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-1', - 'market-3', - ]); - }); - - it('sorts markets by price in descending order when sortMarketsBy is price', () => { - const eventWithMultipleMarkets = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-low-price', - outcomePrices: '["0.3", "0.7"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-high-price', - outcomePrices: '["0.8", "0.2"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-medium-price', - outcomePrices: '["0.5", "0.5"]', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithMultipleMarkets], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(3); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-high-price', - 'market-medium-price', - 'market-low-price', - ]); - }); - - it('handles markets with null outcomePrices in sorting when sortMarketsBy is price', () => { - const eventWithNullPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-with-price', - outcomePrices: '["0.6", "0.4"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-without-price', - outcomePrices: null as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithNullPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(2); - // Market with price comes first (0.6 > 0) - expect(result[0].outcomes[0].id).toBe('market-with-price'); - expect(result[0].outcomes[1].id).toBe('market-without-price'); - }); - - it('handles markets with undefined outcomePrices in sorting when sortMarketsBy is price', () => { - const eventWithUndefinedPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-with-price', - outcomePrices: '["0.3", "0.7"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-without-price', - outcomePrices: undefined as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithUndefinedPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(2); - // Market with price comes first (0.3 > 0) - expect(result[0].outcomes[0].id).toBe('market-with-price'); - expect(result[0].outcomes[1].id).toBe('market-without-price'); - }); - - it('handles markets with empty outcomePrices string in sorting when sortMarketsBy is price', () => { - const eventWithEmptyPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-with-price', - outcomePrices: '["0.4", "0.6"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-with-empty-price', - outcomePrices: '', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithEmptyPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(2); - // Market with price comes first (0.4 > 0) - expect(result[0].outcomes[0].id).toBe('market-with-price'); - expect(result[0].outcomes[1].id).toBe('market-with-empty-price'); - }); - - it('include resolvedBy field in outcome', () => { - const eventWithResolvedBy = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - resolvedBy: '0x1234567890123456789012345678901234567890', - }, - ], - }; - - const result = parsePolymarketEvents([eventWithResolvedBy], mockCategory); - - expect(result[0].outcomes[0].resolvedBy).toBe( - '0x1234567890123456789012345678901234567890', - ); - }); - - it('handle undefined resolvedBy field', () => { - const eventWithoutResolvedBy = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - resolvedBy: undefined as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithoutResolvedBy], - mockCategory, - ); - - expect(result[0].outcomes[0].resolvedBy).toBeUndefined(); - }); - - it('handles complex sorting with mixed price scenarios when sortMarketsBy is price', () => { - const eventWithComplexPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-zero', - outcomePrices: '["0", "1"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-high', - outcomePrices: '["0.9", "0.1"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-medium', - outcomePrices: '["0.5", "0.5"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-null', - outcomePrices: null as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithComplexPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(4); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-high', // 0.9 - 'market-medium', // 0.5 - 'market-zero', // 0 - 'market-null', // 0 (default) - ]); - }); - - it('preserves market order when no sortMarketsBy is provided for non-sport events', () => { - const eventWithMultipleMarkets = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-first', - outcomePrices: '["0.3", "0.7"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-second', - outcomePrices: '["0.8", "0.2"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-third', - outcomePrices: '["0.5", "0.5"]', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithMultipleMarkets], - mockCategory, - ); - - expect(result[0].outcomes).toHaveLength(3); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-first', - 'market-second', - 'market-third', - ]); - }); - - it('populates outcomeGroups for sport event when extendedSportsMarketsLeagues includes league', () => { - const sportEvent: PolymarketApiEvent = { - id: 'nfl-game-1', - slug: 'nfl-sea-den-2025-01-15', - title: 'Seahawks vs. Broncos', - description: 'NFL game', - icon: 'https://example.com/icon.png', - closed: false, - tags: [ - { id: '1', label: 'Sports', slug: 'sports' }, - { id: '2', label: 'Games', slug: 'games' }, - { id: '3', label: 'NFL', slug: 'nfl' }, - ], - series: [], - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'moneyline-1', - sportsMarketType: 'moneyline', - }, - ], - liquidity: 50000, - volume: 100000, - gameId: 'game-123', - }; - const mockTeamLookup = jest.fn((league: string, abbreviation: string) => { - const teams: Record< - string, - Record< - string, - { - id: string; - name: string; - logo: string; - abbreviation: string; - color: string; - alias: string; - } - > - > = { - nfl: { - sea: { - id: 'sea', - name: 'Seahawks', - logo: '', - abbreviation: 'sea', - color: TEST_HEX_COLORS.TEAM_SEA, - alias: 'Seahawks', - }, - den: { - id: 'den', - name: 'Broncos', - logo: '', - abbreviation: 'den', - color: TEST_HEX_COLORS.TEAM_DEN, - alias: 'Broncos', - }, - }, - }; - return teams[league]?.[abbreviation]; - }); - - const resultWithLeague = parsePolymarketEvents([sportEvent], { - category: 'sports', - teamLookup: mockTeamLookup, - extendedSportsMarketsLeagues: ['nfl'], - }); - - expect(resultWithLeague[0].outcomeGroups).toBeDefined(); - expect(Array.isArray(resultWithLeague[0].outcomeGroups)).toBe(true); - - const resultWithoutLeague = parsePolymarketEvents([sportEvent], { - category: 'sports', - teamLookup: mockTeamLookup, - extendedSportsMarketsLeagues: [], - }); - - expect(resultWithoutLeague[0].outcomeGroups).toBeUndefined(); - }); - }); - - describe('isSpreadMarket', () => { - it('returns true when sportsMarketType contains spread', () => { - const spreadMarket: PolymarketApiMarket = { - conditionId: 'spread-market', - question: 'Spread market?', - description: 'A spread market', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A -3.5', - sportsMarketType: 'spreads', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(spreadMarket); - - expect(result).toBe(true); - }); - - it('returns true when sportsMarketType is spread (case insensitive)', () => { - const spreadMarket: PolymarketApiMarket = { - conditionId: 'spread-market', - question: 'Spread market?', - description: 'A spread market', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A -3.5', - sportsMarketType: 'Spreads', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(spreadMarket); - - expect(result).toBe(true); - }); - - it('returns false when sportsMarketType is moneyline', () => { - const moneylineMarket: PolymarketApiMarket = { - conditionId: 'moneyline-market', - question: 'Moneyline market?', - description: 'A moneyline market', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A', - sportsMarketType: 'moneyline', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(moneylineMarket); - - expect(result).toBe(false); - }); - - it('returns false when sportsMarketType is undefined', () => { - const marketWithoutType: PolymarketApiMarket = { - conditionId: 'market-no-type', - question: 'Market?', - description: 'A market without type', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(marketWithoutType); - - expect(result).toBe(false); - }); - }); - - describe('sortGameMarkets', () => { - const createSportMarket = ( - id: string, - sportsMarketType: string, - liquidity: number, - volume: number, - ): PolymarketApiMarket => ({ - conditionId: id, - question: `Market ${id}?`, - description: `Description ${id}`, - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: `Group ${id}`, - sportsMarketType, - status: 'open', - volumeNum: volume, - liquidity, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }); - - it('groups markets by sportsMarketType with moneyline first, spreads second, totals third', () => { - const markets = [ - createSportMarket('totals-1', 'totals', 100, 100), - createSportMarket('moneyline-1', 'moneyline', 100, 100), - createSportMarket('spreads-1', 'spreads', 100, 100), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'spreads-1', - 'totals-1', - ]); - }); - - it('sorts alphabetically for unknown market types', () => { - const markets = [ - createSportMarket('zebra-1', 'zebra', 100, 100), - createSportMarket('alpha-1', 'alpha', 100, 100), - createSportMarket('moneyline-1', 'moneyline', 100, 100), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'alpha-1', - 'zebra-1', - ]); - }); - - it('sorts markets within same group by liquidity + volume descending', () => { - const markets = [ - createSportMarket('moneyline-low', 'moneyline', 100, 100), // score: 200 - createSportMarket('moneyline-high', 'moneyline', 500, 500), // score: 1000 - createSportMarket('moneyline-medium', 'moneyline', 300, 200), // score: 500 - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-high', - 'moneyline-medium', - 'moneyline-low', - ]); - }); - - it('handles markets with undefined sportsMarketType as other', () => { - const markets = [ - createSportMarket('other-1', undefined as any, 100, 100), - createSportMarket('moneyline-1', 'moneyline', 100, 100), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'other-1', - ]); - }); - - it('maintains group ordering with multiple markets per group', () => { - const markets = [ - createSportMarket('totals-low', 'totals', 50, 50), - createSportMarket('spreads-high', 'spreads', 500, 500), - createSportMarket('moneyline-low', 'moneyline', 100, 100), - createSportMarket('totals-high', 'totals', 300, 300), - createSportMarket('spreads-low', 'spreads', 100, 100), - createSportMarket('moneyline-high', 'moneyline', 400, 400), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-high', - 'moneyline-low', - 'spreads-high', - 'spreads-low', - 'totals-high', - 'totals-low', - ]); - }); - }); - - describe('sortMarketsByField', () => { - const createMarketForSorting = ( - id: string, - price: string, - threshold?: number, - ): PolymarketApiMarket => ({ - conditionId: id, - question: `Market ${id}?`, - description: `Description ${id}`, - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: `Group ${id}`, - groupItemThreshold: threshold, - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: price, - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }); - - it('sorts by price descending', () => { - const markets = [ - createMarketForSorting('low', '["0.3", "0.7"]'), - createMarketForSorting('high', '["0.9", "0.1"]'), - createMarketForSorting('medium', '["0.5", "0.5"]'), - ]; - - const result = sortMarketsByField(markets, 'price'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'high', - 'medium', - 'low', - ]); - }); - - it('sorts by groupItemThreshold ascending', () => { - const markets = [ - createMarketForSorting('high', '["0.5", "0.5"]', 100), - createMarketForSorting('low', '["0.5", "0.5"]', 10), - createMarketForSorting('medium', '["0.5", "0.5"]', 50), - ]; - - const result = sortMarketsByField(markets, 'ascending'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'low', - 'medium', - 'high', - ]); - }); - - it('sorts by groupItemThreshold descending', () => { - const markets = [ - createMarketForSorting('high', '["0.5", "0.5"]', 100), - createMarketForSorting('low', '["0.5", "0.5"]', 10), - createMarketForSorting('medium', '["0.5", "0.5"]', 50), - ]; - - const result = sortMarketsByField(markets, 'descending'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'high', - 'medium', - 'low', - ]); - }); - - it('handles undefined groupItemThreshold as 0 for ascending', () => { - const markets = [ - createMarketForSorting('with-threshold', '["0.5", "0.5"]', 50), - createMarketForSorting('without-threshold', '["0.5", "0.5"]'), - ]; - - const result = sortMarketsByField(markets, 'ascending'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'without-threshold', - 'with-threshold', - ]); - }); - - it('handles null outcomePrices as 0 for price sorting', () => { - const markets = [ - createMarketForSorting('with-price', '["0.6", "0.4"]'), - { - ...createMarketForSorting('null-price', ''), - outcomePrices: null as any, - }, - ]; - - const result = sortMarketsByField(markets, 'price'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'with-price', - 'null-price', - ]); - }); - }); - - describe('sortMarkets', () => { - const createEvent = ( - tags: { id: string; label: string; slug: string }[], - markets: PolymarketApiMarket[], - sortBy?: 'price' | 'ascending' | 'descending', - ): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags, - series: [], - markets, - liquidity: 1000, - volume: 5000, - sortBy, - }); - - const createMarket = ( - id: string, - price: string, - liquidity: number, - volume: number, - sportsMarketType?: string, - ): PolymarketApiMarket => ({ - conditionId: id, - question: `Market ${id}?`, - description: `Description ${id}`, - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: `Group ${id}`, - sportsMarketType, - status: 'open', - volumeNum: volume, - liquidity, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: price, - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }); - - it('uses sortBy parameter when provided', () => { - const markets = [ - createMarket('low', '["0.3", "0.7"]', 100, 100), - createMarket('high', '["0.9", "0.1"]', 100, 100), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price' }); - - expect(result.map((m) => m.conditionId)).toEqual(['high', 'low']); - }); - - it('uses sortGameMarkets for game events when no sortBy parameter', () => { - const markets = [ - createMarket('totals-1', '["0.5", "0.5"]', 100, 100, 'totals'), - createMarket('moneyline-1', '["0.5", "0.5"]', 100, 100, 'moneyline'), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, isGameEvent: true }); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'totals-1', - ]); - }); - - it('uses event.sortBy for non-game events when no sortBy parameter', () => { - const markets = [ - createMarket('low', '["0.3", "0.7"]', 100, 100), - createMarket('high', '["0.9", "0.1"]', 100, 100), - ]; - const event = createEvent([], markets, 'price'); - - const result = sortMarkets({ event }); - - expect(result.map((m) => m.conditionId)).toEqual(['high', 'low']); - }); - - it('returns markets unchanged when no sorting specified for non-game events', () => { - const markets = [ - createMarket('first', '["0.3", "0.7"]', 100, 100), - createMarket('second', '["0.9", "0.1"]', 100, 100), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event }); - - expect(result.map((m) => m.conditionId)).toEqual(['first', 'second']); - }); - - it('does not apply game sorting when isGameEvent is not set even with sport tags', () => { - const markets = [ - createMarket('totals-1', '["0.5", "0.5"]', 100, 100, 'totals'), - createMarket('moneyline-1', '["0.5", "0.5"]', 100, 100, 'moneyline'), - ]; - const sportTags = [{ id: '1', label: 'Sports', slug: 'sports' }]; - const event = createEvent(sportTags, markets); - - const result = sortMarkets({ event }); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'totals-1', - 'moneyline-1', - ]); - }); - - it('prioritizes game event sorting over sortBy parameter', () => { - const markets = [ - createMarket('totals-high-price', '["0.9", "0.1"]', 100, 100, 'totals'), - createMarket( - 'moneyline-low-price', - '["0.3", "0.7"]', - 100, - 100, - 'moneyline', - ), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price', isGameEvent: true }); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-low-price', - 'totals-high-price', - ]); - }); - - it('places moneyline first for game events even when moneyline has lower price', () => { - const markets = [ - createMarket( - 'spreads-high-price', - '["0.9", "0.1"]', - 200, - 200, - 'spreads', - ), - createMarket('totals-mid-price', '["0.7", "0.3"]', 150, 150, 'totals'), - createMarket( - 'moneyline-low-price', - '["0.3", "0.7"]', - 100, - 100, - 'moneyline', - ), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price', isGameEvent: true }); - - // Moneyline first despite having the lowest price - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-low-price', - 'spreads-high-price', - 'totals-mid-price', - ]); - }); - - it('uses sortBy parameter for non-game events when sortBy is provided', () => { - const markets = [ - createMarket('low-price', '["0.2", "0.8"]', 100, 100), - createMarket('high-price', '["0.8", "0.2"]', 100, 100), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price' }); - - // Non-game events still respect sortBy - expect(result.map((m) => m.conditionId)).toEqual([ - 'high-price', - 'low-price', - ]); - }); - - it('ignores event.sortBy for game events', () => { - const markets = [ - createMarket('totals-high-price', '["0.9", "0.1"]', 100, 100, 'totals'), - createMarket( - 'moneyline-low-price', - '["0.2", "0.8"]', - 100, - 100, - 'moneyline', - ), - ]; - const event = createEvent([], markets, 'price'); - - const result = sortMarkets({ event, isGameEvent: true }); - - // Game sorting wins over event.sortBy - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-low-price', - 'totals-high-price', - ]); - }); - }); - - describe('parsePolymarketMarket', () => { - const createMarket = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Weather', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - closed: false, - active: true, - resolvedBy: '0x123', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const createTestEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [], - markets: [], - liquidity: 1000, - volume: 5000, - ...overrides, - }); - - it('parses market to PredictOutcome correctly', () => { - const market = createMarket(); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result).toEqual({ - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - title: 'Will it rain?', - description: 'Weather prediction', - image: 'https://example.com/icon.png', - groupItemTitle: 'Weather', - groupItemThreshold: undefined, - status: 'open', - volume: 1000, - liquidity: 500, - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.6 }, - { id: 'token-2', title: 'No', price: 0.4 }, - ], - sportsMarketType: undefined, - negRisk: false, - tickSize: '0.01', - resolvedBy: '0x123', - resolutionStatus: 'unresolved', - }); - }); - - it('uses image when icon is not available', () => { - const market = createMarket({ icon: undefined as any }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result.image).toBe('https://example.com/image.png'); - }); - - it('returns closed status for closed markets', () => { - const market = createMarket({ closed: true }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result.status).toBe('closed'); - }); - - it('formats spread market groupItemTitle by removing dash', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - groupItemTitle: 'Team A -3.5', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - expect(result.groupItemTitle).toBe('Team A 3.5'); - }); - - it('formats spread market groupItemTitle preserving dashes in team names', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - groupItemTitle: 'FC-Dallas -3.5', - }); - const event = createTestEvent({ title: 'FC-Dallas vs. St.-Louis' }); - - const result = parsePolymarketMarket(market, event); - - expect(result.groupItemTitle).toBe('FC-Dallas 3.5'); - }); - - it('formats spread market outcome titles with line values', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - outcomes: '["Team A", "Team B"]', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - // Team A comes first (from event title split) - expect(result.tokens[0].title).toBe('Team A -3.5'); - expect(result.tokens[1].title).toBe('Team B +3.5'); - }); - - it('handles spread markets without line value', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - outcomes: '["Team A", "Team B"]', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - expect(result.tokens[0].title).toBe('Team A'); - expect(result.tokens[1].title).toBe('Team B'); - }); - - it('handles undefined volumeNum as 0', () => { - const market = createMarket({ volumeNum: undefined as any }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result.volume).toBe(0); - }); - - it('sorts spread market outcome tokens with teamA first', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - clobTokenIds: '["token-b", "token-a"]', - outcomes: '["Team B", "Team A"]', - outcomePrices: '["0.4", "0.6"]', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - // Team A should be sorted first based on event title - expect(result.tokens[0].title).toBe('Team A +3.5'); - expect(result.tokens[1].title).toBe('Team B -3.5'); - }); - - describe('with game (shortTitle generation)', () => { - const createGameData = (): PredictMarketGame => ({ - id: 'game-1', - homeTeam: { - id: 'home-1', - name: 'Denver Broncos', - abbreviation: 'DEN', - color: TEST_HEX_COLORS.TEAM_DEN, - alias: 'Broncos', - logo: 'https://example.com/den.png', - }, - awayTeam: { - id: 'away-1', - name: 'Seattle Seahawks', - abbreviation: 'SEA', - color: TEST_HEX_COLORS.TEAM_SEA, - alias: 'Seahawks', - logo: 'https://example.com/sea.png', - }, - startTime: '2024-12-31T20:00:00Z', - status: 'scheduled' as const, - league: 'nfl' as const, - elapsed: null, - period: null, - score: null, - }); - - it('adds team abbreviation shortTitles for moneyline markets', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('adds team abbreviation shortTitles using alias match', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Broncos", "Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ title: 'Broncos vs. Seahawks' }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('adds spread shortTitles with signed line values', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.55", "0.45"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN -3.5'); - expect(result.tokens[1].shortTitle).toBe('SEA +3.5'); - }); - - it('returns abbreviation only for spread markets without line', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'spreads', - line: undefined as any, - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.5", "0.5"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('adds O/U shortTitles for over/under markets', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'totals', - groupItemTitle: 'O/U 45.5', - line: 45.5, - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.52", "0.48"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('O 45.5'); - expect(result.tokens[1].shortTitle).toBe('U 45.5'); - }); - - it('maps Yes/No to Over/Under titles for O/U markets', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'totals', - groupItemTitle: 'O/U 45.5', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.52", "0.48"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].title).toBe('Over'); - expect(result.tokens[1].title).toBe('Under'); - }); - - it('omits shortTitle when outcome name does not match any team', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Unknown Team", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ - title: 'Unknown Team vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('resolves negRisk moneyline shortTitles from groupItemTitle', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Denver Broncos', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('resolves negRisk moneyline shortTitles with mixed-case market type', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'Moneyline', - groupItemTitle: 'Denver Broncos', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].title).toBe('Denver Broncos'); - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('skips negRisk shortTitles for draw markets', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Draw', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.1", "0.9"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBeUndefined(); - }); - - it('skips negRisk shortTitles when groupItemTitle does not match a team', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Some Other Option', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.3", "0.7"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBeUndefined(); - }); - - it('resolves negRisk shortTitles for away team groupItemTitle', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Seattle Seahawks', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.4", "0.6"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('SEA'); - expect(result.tokens[1].shortTitle).toBe('DEN'); - }); - - it('skips shortTitle generation when game is not provided', () => { - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBeUndefined(); - }); - - it('omits shortTitle for spread outcome when name does not match', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - outcomes: '["Unknown", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.5", "0.5"]', - }); - const event = createTestEvent({ - title: 'Unknown vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBe('SEA +3.5'); - }); - }); - }); - - describe('parsePolymarketPositions', () => { - const createPosition = ( - id: string, - index: number, - props: Partial, - ): PolymarketPosition => ({ - asset: `position-${id}`, - conditionId: 'condition-1', - icon: `https://example.com/icon${id}.png`, - title: `Position ${id}`, - slug: `position-${id}`, - size: 100, - eventId: 'event-1', - outcome: 'Yes', - outcomeIndex: index, - cashPnl: 10, - curPrice: 0.6, - currentValue: 60, - percentPnl: 5, - realizedPnl: 0, - initialValue: 50, - avgPrice: 0.5, - redeemable: false, - negativeRisk: false, - endDate: '2024-12-31', - ...props, - }); - - const mockPositions: PolymarketPosition[] = [ - createPosition('1', 0, {}), - createPosition('2', 1, { - size: 50, - outcome: 'No', - cashPnl: -5, - curPrice: 0.4, - currentValue: 20, - percentPnl: -10, - initialValue: 25, - redeemable: true, - }), - createPosition('3', 2, { - size: 75, - outcome: 'Maybe', - cashPnl: 15, - curPrice: 0.8, - percentPnl: 20, - avgPrice: 0.67, - redeemable: true, - }), - ]; - - const mockMarketResponse: Partial[] = [ - { - conditionId: 'condition-1', - events: [ - { - id: 'event-1', - slug: 'slug-1', - title: 'Mock Event', - description: 'Mock Description', - icon: 'mock-icon.png', - closed: false, - tags: [], - series: [], - markets: [], - liquidity: 1000000, - volume: 1000000, - }, - ], - }, - ]; - - beforeEach(() => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockMarketResponse), - }); - }); - - it('parse positions correctly and enrich with market data', async () => { - const result = await parsePolymarketPositions({ - positions: mockPositions, - }); - - expect(result[0]).toEqual({ - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - outcomeId: 'condition-1', - outcome: 'Yes', - outcomeTokenId: 'position-1', - outcomeIndex: 0, - negRisk: false, - amount: 100, - price: 0.6, - status: 'open', - realizedPnl: 0, - percentPnl: 5, - cashPnl: 10, - initialValue: 50, - avgPrice: 0.5, - endDate: '2024-12-31', - title: 'Position 1', - icon: 'https://example.com/icon1.png', - size: 100, - claimable: false, - currentValue: 60, - }); - - expect(result[1]).toEqual({ - id: 'position-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - outcomeId: 'condition-1', - outcome: 'No', - outcomeTokenId: 'position-2', - outcomeIndex: 1, - negRisk: false, - amount: 50, - price: 0.4, - status: 'lost', - realizedPnl: 0, - percentPnl: -10, - cashPnl: -5, - initialValue: 25, - avgPrice: 0.5, - endDate: '2024-12-31', - title: 'Position 2', - icon: 'https://example.com/icon2.png', - size: 50, - claimable: true, - currentValue: 20, - }); - - expect(result[2]).toEqual({ - id: 'position-3', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - outcomeId: 'condition-1', - outcome: 'Maybe', - outcomeTokenId: 'position-3', - outcomeIndex: 2, - negRisk: false, - amount: 75, - price: 0.8, - status: 'won', - realizedPnl: 0, - percentPnl: 20, - cashPnl: 15, - initialValue: 50, - avgPrice: 0.67, - endDate: '2024-12-31', - title: 'Position 3', - icon: 'https://example.com/icon3.png', - size: 75, - claimable: true, - currentValue: 60, - }); - }); - - it('handle empty positions array', async () => { - const result = await parsePolymarketPositions({ positions: [] }); - expect(result).toEqual([]); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - describe('negRisk outcome label resolution', () => { - it('non-negRisk position outcome stays as original', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: false, - outcome: 'Yes', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Yes'); - }); - - it('negRisk position without eventSlug outcome stays as original', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: undefined, - outcome: 'Yes', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Yes'); - }); - - it('negRisk position with non-draw-capable league eventSlug outcome stays as original', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'politics-election-2024', - slug: 'politics-election-2024-candidate-a', - outcome: 'Yes', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Yes'); - }); - - it('negRisk position with UCL eventSlug and draw suffix resolves to Draw', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-draw', - outcome: 'Draw', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Draw'); - }); - - it('negRisk position with UCL eventSlug and team abbreviation with teamLookup resolves to team name', async () => { - const mockTeamLookup = jest.fn( - (league: string, abbreviation: string) => { - if (league === 'ucl' && abbreviation === 'mci') { - return { - id: 'team-1', - name: 'Manchester City', - logo: 'https://example.com/mci.png', - abbreviation: 'mci', - color: 'team-blue', - alias: 'City', - }; - } - return undefined; - }, - ); - - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-mci', - outcome: 'Manchester City', - }), - ]; - - const result = await parsePolymarketPositions({ - positions, - teamLookup: mockTeamLookup, - }); - - expect(result[0].outcome).toBe('Manchester City'); - expect(mockTeamLookup).toHaveBeenCalledWith('ucl', 'mci'); - }); - - it('negRisk position with UCL eventSlug and team abbreviation without teamLookup resolves to uppercase abbreviation', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-mci', - outcome: 'MCI', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('MCI'); - }); - - it('negRisk position with UCL eventSlug and team abbreviation with teamLookup returning undefined resolves to uppercase abbreviation', async () => { - const mockTeamLookup = jest.fn(() => undefined); - - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-xyz', - outcome: 'XYZ', - }), - ]; - - const result = await parsePolymarketPositions({ - positions, - teamLookup: mockTeamLookup, - }); - - expect(result[0].outcome).toBe('XYZ'); - }); - }); - }); - - describe('getPredictPositionStatus', () => { - it.each([ - { claimable: false, cashPnl: 10, expected: PredictPositionStatus.OPEN }, - { claimable: false, cashPnl: -5, expected: PredictPositionStatus.OPEN }, - { claimable: true, cashPnl: 15, expected: PredictPositionStatus.WON }, - { claimable: true, cashPnl: 0, expected: PredictPositionStatus.LOST }, - { claimable: true, cashPnl: -5, expected: PredictPositionStatus.LOST }, - ])( - 'returns $expected when claimable=$claimable and cashPnl=$cashPnl', - ({ claimable, cashPnl, expected }) => { - const result = getPredictPositionStatus({ claimable, cashPnl }); - expect(result).toBe(expected); - }, - ); - }); - - describe('getParsedMarketsFromPolymarketApi', () => { - const mockEvent: PolymarketApiEvent = { - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [{ id: '1', slug: 'test', title: 'Test', recurrence: 'daily' }], - markets: [ - { - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Weather', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 1000000, - volume: 1000000, - }; - - it('fetch markets without search parameters', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const result = await getParsedMarketsFromPolymarketApi(); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('event-1'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&order=volume24hr', - ); - }); - - it('fetch markets with search query', async () => { - const mockResponse = { - events: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - q: 'weather', - limit: 10, - offset: 5, - }; - - const result = await getParsedMarketsFromPolymarketApi(params); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('event-1'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/public-search?q=weather&type=events&events_status=active&sort=volume_24hr&presets=EventsTitle&limit_per_type=10&page=1', - ); - }); - - it('returns empty array when search results omit markets', async () => { - const eventWithoutMarkets = { - ...mockEvent, - markets: undefined, - } as unknown as PolymarketApiEvent; - - const mockResponse = { - events: [eventWithoutMarkets], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - q: 'nhl', - limit: 10, - offset: 0, - }; - - const result = await getParsedMarketsFromPolymarketApi(params); - - expect(result).toEqual([]); - }); - - it('returns empty tags when search results omit tags', async () => { - const eventWithoutTags = { - ...mockEvent, - tags: undefined, - } as unknown as PolymarketApiEvent; - - const mockResponse = { - events: [eventWithoutTags], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - q: 'nhl', - limit: 10, - offset: 0, - }; - - const result = await getParsedMarketsFromPolymarketApi(params); - - expect(result).toHaveLength(1); - expect(result[0].tags).toEqual([]); - }); - - it('handle different categories', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'crypto', - limit: 5, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=5&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&tag_slug=crypto&order=volume24hr', - ); - }); - - it('return empty array for invalid response', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({}), - }); - - const result = await getParsedMarketsFromPolymarketApi(); - - expect(result).toEqual([]); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - await expect(getParsedMarketsFromPolymarketApi()).rejects.toThrow( - 'Network error', - ); - }); - - describe('hot tab with customQueryParams', () => { - it('uses only limit, offset, and customQueryParams when category is hot', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'hot', - customQueryParams: 'tag_id=149&tag_id=100995&order=volume24hr', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&offset=0&tag_id=149&tag_id=100995&order=volume24hr', - ); - }); - - it('falls back to default params when hot tab has no customQueryParams', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'hot', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&order=volume24hr', - ); - }); - - it('does not apply default filters for hot tab with customQueryParams', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'hot', - customQueryParams: 'tag_id=198', - limit: 10, - offset: 20, - }; - - await getParsedMarketsFromPolymarketApi(params); - - const callUrl = mockFetch.mock.calls[0][0] as string; - - expect(callUrl).not.toContain('active=true'); - expect(callUrl).not.toContain('archived=false'); - expect(callUrl).not.toContain('closed=false'); - expect(callUrl).not.toContain('liquidity_min'); - expect(callUrl).not.toContain('volume_min'); - expect(callUrl).toContain('limit=10'); - expect(callUrl).toContain('offset=20'); - expect(callUrl).toContain('tag_id=198'); - }); - - it('appends customQueryParams to standard category pagination queries', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'trending', - customQueryParams: 'tag_id=149', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&order=volume24hr&tag_id=149', - ); - }); - - it('appends customQueryParams for sports category', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'sports', - customQueryParams: 'tag_id=10', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&tag_slug=sports&order=volume24hr&tag_id=10', - ); - }); - }); - }); - - describe('getMarketsFromPolymarketApi', () => { - const mockMarket: PolymarketApiMarket = { - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Weather', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }; - - it('fetch single market successfully', async () => { - const mockResponse = [mockMarket]; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const result = await getMarketsFromPolymarketApi({ - conditionIds: ['market-1'], - }); - - expect(result).toEqual(mockResponse); - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/markets?condition_ids=market-1', - ); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - await expect( - getMarketsFromPolymarketApi({ conditionIds: ['market-1'] }), - ).rejects.toThrow('Network error'); - }); - }); - - describe('encodeRedeemPositions', () => { - it('encode redeem positions function call correctly', () => { - const collateralToken = '0x1234567890123456789012345678901234567890'; - const parentCollectionId = HASH_ZERO_BYTES32; - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const indexSets = [1, 2]; - - const result = encodeRedeemPositions({ - collateralToken, - parentCollectionId, - conditionId, - indexSets, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('handle different index sets', () => { - const collateralToken = '0x1234567890123456789012345678901234567890'; - const parentCollectionId = HASH_ZERO_BYTES32; - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const indexSets = [1, 2, 3, 4]; - - const result = encodeRedeemPositions({ - collateralToken, - parentCollectionId, - conditionId, - indexSets, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('handle bigint amounts', () => { - const collateralToken = '0x1234567890123456789012345678901234567890'; - const parentCollectionId = HASH_ZERO_BYTES32; - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const indexSets = [BigInt(1), BigInt(2)]; - - const result = encodeRedeemPositions({ - collateralToken, - parentCollectionId, - conditionId, - indexSets, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - }); - - describe('encodeRedeemNegRiskPositions', () => { - it('encode redeem neg risk positions function call correctly', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const amounts = [100, 200]; - - const result = encodeRedeemNegRiskPositions({ - conditionId, - amounts, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('handle bigint amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const amounts = [BigInt(100), BigInt(200)]; - - const result = encodeRedeemNegRiskPositions({ - conditionId, - amounts, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('handle string amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const amounts = ['100', '200']; - - const result = encodeRedeemNegRiskPositions({ - conditionId, - amounts, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - }); - - describe('encodeClaim', () => { - it('encode claim for non-negRisk positions', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = false; - - const result = encodeClaim(conditionId, negRisk); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('encode claim for negRisk positions with amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - const amounts = [100, 200]; - - const result = encodeClaim(conditionId, negRisk, amounts); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('throw error for negRisk positions without amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - - expect(() => encodeClaim(conditionId, negRisk)).toThrow( - 'amounts parameter is required when negRisk is true', - ); - }); - - it('handle bigint amounts for negRisk positions', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - const amounts = [BigInt(100), BigInt(200)]; - - const result = encodeClaim(conditionId, negRisk, amounts); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('handle string amounts for negRisk positions', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - const amounts = ['100', '200']; - - const result = encodeClaim(conditionId, negRisk, amounts); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - }); - - describe('calculateFees', () => { - const feeCollection = DEFAULT_FEE_COLLECTION_FLAG; - const totalFeePercentage = - (feeCollection.metamaskFee + feeCollection.providerFee) * 100; - - beforeEach(() => { - // Mock the Gamma API response for market details - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ - id: 'market-1', - tags: [], - }), - }); - }); - - it('calculates fee using feeCollection config', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 1, - }; - - const fees = await calculateFees(params); - - const expectedMetamaskFee = - params.userBetAmount * feeCollection.metamaskFee; - const expectedProviderFee = - params.userBetAmount * feeCollection.providerFee; - const expectedTotal = expectedMetamaskFee + expectedProviderFee; - expect(fees.totalFee).toBe(expectedTotal); - expect(fees.providerFee).toBe(expectedProviderFee); - expect(fees.metamaskFee).toBe(expectedMetamaskFee); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - expect(fees.executors).toEqual(feeCollection.executors ?? []); - expect(fees.permit2Enabled).toBe(feeCollection.permit2Enabled ?? false); - }); - - it('calculates fees correctly for various amounts', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 1, - }; - - const fees = await calculateFees(params); - - expect(fees.providerFee).toBeGreaterThanOrEqual(0); - expect(fees.metamaskFee).toBeGreaterThanOrEqual(0); - expect(fees.totalFee).toBeGreaterThanOrEqual(0); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - }); - - it('handles large amounts correctly', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - const expectedMetamaskFee = - params.userBetAmount * feeCollection.metamaskFee; - const expectedProviderFee = - params.userBetAmount * feeCollection.providerFee; - const expectedTotal = expectedMetamaskFee + expectedProviderFee; - expect(fees.totalFee).toBe(expectedTotal); - expect(fees.providerFee).toBe(expectedProviderFee); - expect(fees.metamaskFee).toBe(expectedMetamaskFee); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - }); - - it('handles small amounts correctly', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 0.25, - }; - - const fees = await calculateFees(params); - - expect(typeof fees.providerFee).toBe('number'); - expect(typeof fees.metamaskFee).toBe('number'); - expect(typeof fees.totalFee).toBe('number'); - const expectedMetamaskFee = - params.userBetAmount * feeCollection.metamaskFee; - const expectedProviderFee = - params.userBetAmount * feeCollection.providerFee; - const expectedTotal = expectedMetamaskFee + expectedProviderFee; - expect(fees.totalFee).toBe(expectedTotal); - expect(fees.providerFee).toBe(expectedProviderFee); - expect(fees.metamaskFee).toBe(expectedMetamaskFee); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - }); - - it('returns zero fees when feeCollection is not provided', async () => { - const params = { - marketId: 'market-1', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - expect(fees.providerFee).toBe(0); - expect(fees.metamaskFee).toBe(0); - expect(fees.totalFee).toBe(0); - expect(fees.totalFeePercentage).toBe(0); - expect(fees.collector).toBe('0x0'); - expect(fees.executors).toEqual([]); - expect(fees.permit2Enabled).toBe(false); - }); - - it('waives fees for markets in waiveList', async () => { - // Mock market with a tag that's in the waiveList - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ - id: 'market-with-waived-fees', - tags: [{ slug: 'middle-east' }], - }), - }); - - const feeCollectionWithWaiveList = { - ...feeCollection, - waiveList: ['middle-east'], - }; - - const params = { - feeCollection: feeCollectionWithWaiveList, - marketId: 'market-with-waived-fees', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - expect(fees.providerFee).toBe(0); - expect(fees.metamaskFee).toBe(0); - expect(fees.totalFee).toBe(0); - expect(fees.totalFeePercentage).toBe(0); - expect(fees.collector).toBe('0x0'); - expect(fees.executors).toEqual([]); - expect(fees.permit2Enabled).toBe(false); - }); - - it('returns executors and permit2Enabled from feeCollection config', async () => { - const params = { - feeCollection: { - ...feeCollection, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - marketId: 'market-1', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - expect(fees.executors).toEqual([ - '0x1111111111111111111111111111111111111111', - ]); - expect(fees.permit2Enabled).toBe(true); - }); - }); - - describe('submitClobOrder error handling', () => { - const mockHeaders: ClobHeaders = { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - }; - - const mockClobOrder: ClobOrderObject = { - order: { - maker: mockAddress, - signer: mockAddress, - taker: '0x0000000000000000000000000000000000000000', - tokenId: 'test-token', - makerAmount: '100000000', - takerAmount: '50000000', - expiration: '0', - nonce: '0', - feeRateBps: '0', - side: Side.BUY, - signatureType: SignatureType.EOA, - signature: 'mock-signature', - salt: 12345, - }, - owner: mockAddress, - orderType: OrderType.FOK, - }; - - it('handle 403 geoblock response with specific error message', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: jest.fn().mockResolvedValue({}), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'You are unable to access this provider.', - }); - }); - - it('handle non-403 error with JSON error message', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: jest.fn().mockResolvedValue({ - errorMsg: 'Invalid order parameters', - }), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Invalid order parameters', - }); - }); - - it('handle non-403 error without JSON error field, use statusText', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - json: jest.fn().mockResolvedValue({}), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Internal Server Error', - }); - }); - - it('handle non-JSON error response (HTML body)', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 502, - statusText: 'Bad Gateway', - json: jest.fn().mockRejectedValue(new Error('Unexpected token <')), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Bad Gateway', - }); - }); - }); - - describe('parsePolymarketActivity', () => { - // Type guard helpers for better type safety - const isBuyEntry = ( - entry: PredictActivityEntry, - ): entry is PredictActivityBuy => entry.type === 'buy'; - - const isSellEntry = ( - entry: PredictActivityEntry, - ): entry is PredictActivitySell => entry.type === 'sell'; - - it('returns empty array for non-array input', () => { - // @ts-expect-error testing invalid input - expect(parsePolymarketActivity(null)).toEqual([]); - // @ts-expect-error testing invalid input - expect(parsePolymarketActivity(undefined)).toEqual([]); - }); - - it('maps TRADE BUY to buy entries', () => { - const input = [ - { - type: 'TRADE' as const, - side: 'BUY' as const, - timestamp: 1000, - usdcSize: 12.34, - price: 0.56, - conditionId: 'cid-1', - outcomeIndex: 0, - title: 'Market A', - outcome: 'Yes' as const, - icon: 'https://a.png', - transactionHash: '0xhash1', - }, - ]; - const result = parsePolymarketActivity(input); - const activity = result[0]; - const entry = activity.entry; - expect(entry.type).toBe('buy'); - expect(isBuyEntry(entry)).toBe(true); - if (isBuyEntry(entry)) { - expect(entry.price).toBe(0.56); - expect(entry.amount).toBe(12.34); - } - expect(activity.outcome).toBe('Yes'); - expect(activity.title).toBe('Market A'); - expect(activity.icon).toBe('https://a.png'); - }); - - it('maps TRADE SELL to sell entries', () => { - const input = [ - { - type: 'TRADE' as const, - side: 'SELL' as const, - timestamp: 2000, - usdcSize: 9.99, - price: 0.12, - conditionId: 'cid-2', - outcomeIndex: 1, - title: 'Market B', - outcome: 'No' as const, - icon: 'https://b.png', - transactionHash: '0xhash2', - }, - ]; - const result = parsePolymarketActivity(input); - const entry = result[0].entry; - expect(entry.type).toBe('sell'); - expect(isSellEntry(entry)).toBe(true); - if (isSellEntry(entry)) { - expect(entry.price).toBe(0.12); - expect(entry.amount).toBe(9.99); - expect(entry.outcomeId).toBe('cid-2'); - } - }); - - it('maps REDEEM with payout to claimWinnings entries', () => { - const input = [ - { - type: 'REDEEM' as const, - side: '' as const, - timestamp: 3000, - usdcSize: 1.23, // Winning claim with actual payout - price: 0, - conditionId: '', - outcomeIndex: 0, - title: 'Market C', - outcome: '' as const, - icon: '', - transactionHash: '0xhash3', - }, - ]; - const result = parsePolymarketActivity(input); - expect(result).toHaveLength(1); - expect(result[0].entry.type).toBe('claimWinnings'); - expect(result[0].entry.amount).toBe(1.23); - expect(result[0].id).toBe('0xhash3'); - }); - - it('generates fallback id and timestamp when missing', () => { - const input = [ - { - type: 'TRADE' as const, - side: 'BUY' as const, - timestamp: 0, - usdcSize: 0, - price: 0, - conditionId: '', - outcomeIndex: 0, - title: '', - outcome: '' as const, - icon: '', - transactionHash: '', - }, - ]; - const result = parsePolymarketActivity(input); - expect(result[0].id).toBeDefined(); - expect(typeof result[0].entry.timestamp).toBe('number'); - }); - }); - - describe('decimalPlaces', () => { - it('returns 0 for integers', () => { - expect(decimalPlaces(5)).toBe(0); - expect(decimalPlaces(100)).toBe(0); - expect(decimalPlaces(0)).toBe(0); - }); - - it('returns correct decimal places for decimals', () => { - expect(decimalPlaces(1.5)).toBe(1); - expect(decimalPlaces(0.123)).toBe(3); - expect(decimalPlaces(3.14159)).toBe(5); - }); - - it('returns 0 for numbers without decimal part', () => { - expect(decimalPlaces(10.0)).toBe(0); - }); - }); - - describe('roundNormal', () => { - it('rounds numbers to specified decimals', () => { - expect(roundNormal(1.235, 2)).toBe(1.24); - expect(roundNormal(1.234, 2)).toBe(1.23); - expect(roundNormal(1.5, 0)).toBe(2); - }); - - it('returns same number if already at or below target decimals', () => { - expect(roundNormal(1.5, 2)).toBe(1.5); - expect(roundNormal(1, 2)).toBe(1); - }); - - it('handles zero decimals', () => { - expect(roundNormal(1.6, 0)).toBe(2); - expect(roundNormal(1.4, 0)).toBe(1); - }); - }); - - describe('roundDown', () => { - it('rounds down to specified decimals', () => { - expect(roundDown(1.239, 2)).toBe(1.23); - expect(roundDown(1.999, 2)).toBe(1.99); - expect(roundDown(1.5, 0)).toBe(1); - }); - - it('returns same number if already at or below target decimals', () => { - expect(roundDown(1.5, 2)).toBe(1.5); - expect(roundDown(1, 2)).toBe(1); - }); - - it('handles edge cases', () => { - expect(roundDown(0.999, 2)).toBe(0.99); - expect(roundDown(100.123456, 3)).toBe(100.123); - }); - }); - - describe('roundUp', () => { - it('rounds up to specified decimals', () => { - expect(roundUp(1.231, 2)).toBe(1.24); - expect(roundUp(1.001, 2)).toBe(1.01); - expect(roundUp(1.5, 0)).toBe(2); - }); - - it('returns same number if already at or below target decimals', () => { - expect(roundUp(1.5, 2)).toBe(1.5); - expect(roundUp(1, 2)).toBe(1); - }); - - it('handles edge cases', () => { - expect(roundUp(0.001, 2)).toBe(0.01); - expect(roundUp(100.123456, 3)).toBe(100.124); - }); - }); - - describe('roundOrderAmount', () => { - it('returns same amount if decimal places are within limit', () => { - expect(roundOrderAmount({ amount: 1.5, decimals: 2 })).toBe(1.5); - expect(roundOrderAmount({ amount: 10.25, decimals: 2 })).toBe(10.25); - expect(roundOrderAmount({ amount: 5, decimals: 2 })).toBe(5); - }); - - it('rounds down amount if it exceeds decimals after rounding up', () => { - expect(roundOrderAmount({ amount: 1.235, decimals: 2 })).toBe(1.23); - expect(roundOrderAmount({ amount: 10.999, decimals: 2 })).toBe(10.99); - }); - - it('rounds down when amount has more decimals than target', () => { - expect(roundOrderAmount({ amount: 1.001, decimals: 2 })).toBe(1); - expect(roundOrderAmount({ amount: 0.0001, decimals: 2 })).toBe(0); - expect(roundOrderAmount({ amount: 1.0001, decimals: 2 })).toBe(1); - }); - - it('handles zero decimals', () => { - expect(roundOrderAmount({ amount: 1.5, decimals: 0 })).toBe(1); - expect(roundOrderAmount({ amount: 1.999, decimals: 0 })).toBe(1); - expect(roundOrderAmount({ amount: 5, decimals: 0 })).toBe(5); - }); - - it('handles large decimal precision', () => { - expect(roundOrderAmount({ amount: 1.123456789, decimals: 6 })).toBe( - 1.123456, - ); - expect(roundOrderAmount({ amount: 0.123456789, decimals: 5 })).toBe( - 0.12345, - ); - }); - - it('handles edge case with very small amounts', () => { - expect(roundOrderAmount({ amount: 0.00001, decimals: 2 })).toBe(0); - expect(roundOrderAmount({ amount: 0.000001, decimals: 4 })).toBe(0); - expect(roundOrderAmount({ amount: 0.123456, decimals: 4 })).toBe(0.1234); - }); - - it('handles edge case with large amounts', () => { - expect(roundOrderAmount({ amount: 1000.123456, decimals: 2 })).toBe( - 1000.12, - ); - expect(roundOrderAmount({ amount: 99999.999999, decimals: 3 })).toBe( - 99999.999, - ); - }); - - it('applies roundUp with extra decimals then roundDown if needed', () => { - const amount = 1.12345678; - const decimals = 2; - const result = roundOrderAmount({ amount, decimals }); - expect(result).toBe(1.12); - expect(decimalPlaces(result)).toBeLessThanOrEqual(decimals); - }); - - it('rounds up when amount can fit exactly into target decimals', () => { - expect(roundOrderAmount({ amount: 1.2345, decimals: 2 })).toBe(1.23); - expect(roundOrderAmount({ amount: 10.1234567, decimals: 4 })).toBe( - 10.1234, - ); - }); - - it('handles negative amounts', () => { - expect(roundOrderAmount({ amount: -1.235, decimals: 2 })).toBe(-1.24); - expect(roundOrderAmount({ amount: -10.999, decimals: 2 })).toBe(-11); - }); - - it('handles amounts that round up to exceed decimals', () => { - expect(roundOrderAmount({ amount: 1.996, decimals: 2 })).toBe(1.99); - expect(roundOrderAmount({ amount: 0.999999, decimals: 2 })).toBe(0.99); - }); - }); - - describe('previewOrder', () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it('previews BUY order successfully', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [ - { price: '0.50', size: '100' }, - { price: '0.51', size: '50' }, - ], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ base_fee: 30 }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - }); - - expect(result.side).toBe(Side.BUY); - expect(result.marketId).toBe('market-1'); - expect(result.sharePrice).toBeGreaterThan(0); - expect(result.maxAmountSpent).toBeGreaterThan(0); - expect(result.slippage).toBeDefined(); - expect(result.feeRateBps).toBe('30'); - }); - - it('previews SELL order successfully', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [ - { price: '0.50', size: '100' }, - { price: '0.49', size: '50' }, - ], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ base_fee: 15 }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.SELL, - size: 50, - }); - - expect(result.side).toBe(Side.SELL); - expect(result.marketId).toBe('market-1'); - expect(result.sharePrice).toBeGreaterThan(0); - expect(result.fees).toBeUndefined(); - expect(result.feeRateBps).toBe('15'); - }); - - it('uses the v2 order book endpoint and zero fee rate for v2 previews', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [ - { price: '0.50', size: '100' }, - { price: '0.51', size: '50' }, - ], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - isV2: true, - }); - - expect(result.feeRateBps).toBe('0'); - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/book?token_id=token-1`, - { method: 'GET' }, - ); - }); - - it('uses the provided v2 CLOB host override during preview', async () => { - const mockOrderBook = { - min_order_size: '5', - tick_size: '0.01', - timestamp: '2025-02-08T00:00:00.000Z', - neg_risk: false, - asks: [{ price: '0.50', size: '100' }], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - isV2: true, - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/book?token_id=token-1`, - { method: 'GET' }, - ); - }); - - it('throws error when orderbook is not available', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => null, - }); - - await expect( - previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - }), - ).rejects.toThrow('PREDICT_PREVIEW_NO_ORDER_BOOK'); - }); - - it('throws error for BUY when no asks available', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - await expect( - previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - }), - ).rejects.toThrow('PREDICT_PREVIEW_NO_ORDER_MATCH_BUY'); - }); - - it('throws error for SELL when no bids available', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - await expect( - previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.SELL, - size: 50, - }), - ).rejects.toThrow('PREDICT_PREVIEW_NO_ORDER_MATCH_SELL'); - }); - - it('includes fees for BUY orders', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [{ price: '0.50', size: '200' }], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tags: [] }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }); - - expect(result.fees).toBeDefined(); - expect(result.fees?.totalFee).toBeGreaterThanOrEqual(0); - expect(result.fees?.metamaskFee).toBeGreaterThanOrEqual(0); - expect(result.fees?.providerFee).toBeGreaterThanOrEqual(0); - }); - - it('does not include fees for SELL orders', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [{ price: '0.50', size: '200' }], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tags: [] }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.SELL, - size: 100, - }); - - expect(result.fees).toBeUndefined(); - }); - - it('handles negRisk markets', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: true, - asks: [{ price: '0.50', size: '200' }], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tags: [] }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }); - - expect(result.negRisk).toBe(true); - }); - }); - - describe('fetchCarouselFromPolymarketApi', () => { - const carouselEndpoint = 'https://polymarket.com/api/homepage/carousel'; - - const createCarouselItem = (overrides = {}) => ({ - event: { - id: 'event-1', - slug: 'event-1', - title: 'Event 1', - description: 'event description', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [], - markets: [ - { - conditionId: 'market-1', - question: 'Question?', - description: 'market description', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Option group', - status: 'open', - volumeNum: 100, - liquidity: 100, - negRisk: false, - clobTokenIds: ['1', '2'], - outcomes: ['Yes', 'No'], - outcomePrices: ['0.6', '0.4'], - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 100, - volume: 200, - }, - type: 'sports', - shortName: 'S', - options: [], - ...overrides, - }); - - it('fetches from the carousel endpoint and returns items', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(mockFetch).toHaveBeenCalledWith(carouselEndpoint); - expect(result).toHaveLength(1); - expect(result[0].event.id).toBe('event-1'); - }); - - it('returns empty array when response is not an array', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ items: [createCarouselItem()] }), - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result).toEqual([]); - }); - - it('throws when response is not ok', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - json: async () => ({}), - }); - - await expect(fetchCarouselFromPolymarketApi()).rejects.toThrow( - 'Failed to fetch carousel data', - ); - }); - - it('normalizes array-type outcomes to JSON strings', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].outcomes).toBe('["Yes","No"]'); - }); - - it('normalizes array-type outcomePrices to JSON strings', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].outcomePrices).toBe('["0.6","0.4"]'); - }); - - it('normalizes array-type clobTokenIds to JSON strings', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].clobTokenIds).toBe('["1","2"]'); - }); - - it('leaves string-type fields unchanged', async () => { - const responseItems = [ - createCarouselItem({ - event: { - ...createCarouselItem().event, - markets: [ - { - ...createCarouselItem().event.markets[0], - outcomes: '["Yes","No"]', - outcomePrices: '["0.6","0.4"]', - clobTokenIds: '["1","2"]', - }, - ], - }, - }), - ]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].outcomes).toBe('["Yes","No"]'); - expect(result[0].event.markets[0].outcomePrices).toBe('["0.6","0.4"]'); - expect(result[0].event.markets[0].clobTokenIds).toBe('["1","2"]'); - }); - }); - - describe('getAllowanceCalls', () => { - it('returns array of allowance transaction calls', () => { - const calls = getAllowanceCalls({ address: mockAddress }); - - expect(Array.isArray(calls)).toBe(true); - expect(calls.length).toBeGreaterThan(0); - calls.forEach((call) => { - expect(call).toHaveProperty('data'); - expect(call).toHaveProperty('to'); - expect(call).toHaveProperty('chainId'); - expect(call).toHaveProperty('from'); - expect(call).toHaveProperty('value'); - expect(call.from).toBe(mockAddress); - }); - }); - - it('includes all necessary approval calls', () => { - const calls = getAllowanceCalls({ address: mockAddress }); - expect(calls.length).toBe(6); - }); - }); - - describe('buildOutcomeGroups', () => { - const createMockPolymarketApiMarket = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'condition-default', - question: 'Market?', - description: 'Description', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Group', - status: 'open', - volumeNum: 100, - liquidity: 100, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const createMockOutcome = ( - id: string, - overrides?: Partial, - ): PredictOutcome => ({ - id, - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - title: `Market ${id}`, - description: `Description ${id}`, - image: 'https://example.com/icon.png', - groupItemTitle: `Group ${id}`, - status: 'open', - volume: 100, - liquidity: 100, - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.5 }, - { id: 'token-2', title: 'No', price: 0.5 }, - ], - negRisk: false, - tickSize: '0.01', - ...overrides, - }); - - it('groups mixed sport event into game-lines, first-half, and touchdowns', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - }), - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - }), - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - }), - createMockPolymarketApiMarket({ - conditionId: 'at-1', - sportsMarketType: 'anytime_touchdowns', - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(3); - expect(result.map((g) => g.key)).toEqual([ - 'game_lines', - 'first_half', - 'touchdowns', - ]); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - 'totals', - ]); - expect(result[1].outcomes.map((o) => o.id)).toEqual(['fhs-1']); - expect(result[1].subgroups).toBeUndefined(); - expect(result[2].outcomes.map((o) => o.id)).toEqual(['at-1']); - expect(result[2].subgroups).toBeUndefined(); - }); - - it('groups all standard market types into single game-lines group', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - 'totals', - ]); - expect(result[0].subgroups?.[0].outcomes.map((o) => o.id)).toEqual([ - 'ml-1', - ]); - expect(result[0].subgroups?.[1].outcomes.map((o) => o.id)).toEqual([ - 'sp-1', - ]); - expect(result[0].subgroups?.[2].outcomes.map((o) => o.id)).toEqual([ - 'to-1', - ]); - }); - - it('falls back unknown sportsMarketType to game-lines', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'unknown-1', - sportsMarketType: 'some_new_type', - }), - ]; - const outcomes = [ - createMockOutcome('unknown-1', { - sportsMarketType: 'some_new_type', - }), - ]; - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - }); - - it('falls back undefined sportsMarketType to game-lines', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'undef-1', - sportsMarketType: undefined, - }), - ]; - const outcomes = [createMockOutcome('undef-1')]; - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - }); - - it('groups single mapped type into standalone group', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - }), - ]; - const outcomes = [ - createMockOutcome('fhs-1', { - sportsMarketType: 'first_half_spreads', - }), - ]; - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('first_half'); - }); - - it('returns empty array for empty inputs', () => { - const result = buildOutcomeGroups([]); - - expect(result).toEqual([]); - }); - - it('sorts game-lines subgroups by sportsMarketType priority', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - liquidity: 10, - volumeNum: 10, - }), - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 500, - volumeNum: 500, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 200, - volumeNum: 200, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - 'totals', - ]); - }); - - it('orders groups by GROUP_ORDER priority with unknown keys at end', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'at-1', - sportsMarketType: 'anytime_touchdowns', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result.map((g) => g.key)).toEqual([ - 'game_lines', - 'first_half', - 'touchdowns', - ]); - expect(GROUP_ORDER.indexOf('game_lines')).toBeLessThan( - GROUP_ORDER.indexOf('first_half'), - ); - expect(GROUP_ORDER.indexOf('first_half')).toBeLessThan( - GROUP_ORDER.indexOf('touchdowns'), - ); - expect(SPORTS_MARKET_TYPE_TO_GROUP.first_half_spreads).toBe('first_half'); - expect(SPORTS_MARKET_TYPE_TO_GROUP.anytime_touchdowns).toBe('touchdowns'); - }); - - it('tiebreaks game-lines outcomes by liquidity+volume when sportsMarketType priority is equal', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'sp-low', - sportsMarketType: 'spreads', - liquidity: 50, - volumeNum: 50, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-high', - sportsMarketType: 'spreads', - liquidity: 500, - volumeNum: 500, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].outcomes.map((o) => o.id)).toEqual([ - 'sp-high', - 'sp-low', - ]); - }); - - it('sorts first-half subgroups by normalized sportsMarketType priority (moneyline, spreads, totals)', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'fht-1', - sportsMarketType: 'first_half_totals', - liquidity: 500, - volumeNum: 500, - }), - createMockPolymarketApiMarket({ - conditionId: 'fhm-1', - sportsMarketType: 'first_half_moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - liquidity: 300, - volumeNum: 300, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('first_half'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'first_half_moneyline', - 'first_half_spreads', - 'first_half_totals', - ]); - }); - - it('creates subgroups for touchdowns group with anytime and first types', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'at-1', - sportsMarketType: 'anytime_touchdowns', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'ft-1', - sportsMarketType: 'first_touchdowns', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('touchdowns'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups).toHaveLength(2); - expect(result[0].subgroups?.map((s) => s.key)).toEqual( - expect.arrayContaining(['anytime_touchdowns', 'first_touchdowns']), - ); - }); - - it('keeps single-type group flat without subgroups (points)', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'pts-1', - sportsMarketType: 'points', - liquidity: 200, - volumeNum: 200, - }), - createMockPolymarketApiMarket({ - conditionId: 'pts-2', - sportsMarketType: 'points', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('points'); - expect(result[0].outcomes).toHaveLength(2); - expect(result[0].outcomes.map((o) => o.id)).toEqual(['pts-1', 'pts-2']); - expect(result[0].subgroups).toBeUndefined(); - }); - - it('creates subgroups for game-lines with moneyline and spreads only', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups).toHaveLength(2); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - ]); - }); - - it('keeps game-lines flat when only moneyline exists', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toHaveLength(1); - expect(result[0].outcomes[0].id).toBe('ml-1'); - expect(result[0].subgroups).toBeUndefined(); - }); - - it('sorts outcomes by liquidity+volume within each subgroup', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-low', - sportsMarketType: 'spreads', - liquidity: 50, - volumeNum: 50, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-high', - sportsMarketType: 'spreads', - liquidity: 500, - volumeNum: 500, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - const spreadsSubgroup = result[0].subgroups?.find( - (s) => s.key === 'spreads', - ); - expect(spreadsSubgroup?.outcomes.map((o) => o.id)).toEqual([ - 'sp-high', - 'sp-low', - ]); - }); - - it('mixed event produces subgrouped and flat groups', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'pts-1', - sportsMarketType: 'points', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - const gameLines = result.find((g) => g.key === 'game_lines'); - const points = result.find((g) => g.key === 'points'); - expect(gameLines?.subgroups).toHaveLength(3); - expect(gameLines?.outcomes).toEqual([]); - expect(points?.outcomes).toHaveLength(1); - expect(points?.subgroups).toBeUndefined(); - }); - - it('multiple spread thresholds within spreads subgroup', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 300, - volumeNum: 300, - groupItemThreshold: 3.5, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-2', - sportsMarketType: 'spreads', - liquidity: 200, - volumeNum: 200, - groupItemThreshold: 7.5, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-3', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - groupItemThreshold: 10.5, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - const spreadsSubgroup = result[0].subgroups?.find( - (s) => s.key === 'spreads', - ); - expect(spreadsSubgroup?.outcomes).toHaveLength(3); - expect(spreadsSubgroup?.outcomes.map((o) => o.id)).toEqual([ - 'sp-1', - 'sp-2', - 'sp-3', - ]); - }); - }); - - describe('parsePolymarketMarket - sportsMarketType mapping', () => { - const createMarketForSportsType = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Weather', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - closed: false, - active: true, - resolvedBy: '0x123', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const createEventForSportsType = (): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [], - markets: [], - liquidity: 1000, - volume: 5000, - }); - - it('parsePolymarketMarket maps sportsMarketType from raw market', () => { - const market = createMarketForSportsType({ - sportsMarketType: 'spreads', - }); - const event = createEventForSportsType(); - - const result = parsePolymarketMarket(market, event); - - expect(result.sportsMarketType).toBe('spreads'); - }); - - it('parsePolymarketMarket maps undefined when raw market has no sportsMarketType', () => { - const market = createMarketForSportsType(); - const event = createEventForSportsType(); - - const result = parsePolymarketMarket(market, event); - - expect(result.sportsMarketType).toBeUndefined(); - }); - }); - - describe('parsePolymarketEvents - series metadata', () => { - const mockCategory: PredictCategory = 'trending'; - - const createMockEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'series-event-1', - slug: 'series-event', - title: 'Series Event', - description: 'Series event description', - icon: 'https://example.com/series-icon.png', - closed: false, - tags: [], - series: [], - markets: [ - { - conditionId: 'series-market-1', - question: 'Will BTC move up?', - description: 'Series event description', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Crypto', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 1000000, - volume: 1000000, - ...overrides, - }); - - it('maps the first series item onto the parsed market', () => { - const series = { - id: '10684', - slug: 'btc-up-or-down-5m', - title: 'BTC Up or Down 5m', - recurrence: '5m', - }; - const event = createMockEvent({ series: [series] }); - - const result = parsePolymarketEvents([event], mockCategory); - - expect(result[0].series).toEqual(series); - }); - - it('omits series when the event series array is empty', () => { - const event = createMockEvent({ series: [] }); - - const result = parsePolymarketEvents([event], mockCategory); - - expect(result[0].series).toBeUndefined(); - }); - - it('uses the first series item when multiple series are present', () => { - const firstSeries = { - id: '10684', - slug: 'btc-up-or-down-5m', - title: 'BTC Up or Down 5m', - recurrence: '5m', - }; - const secondSeries = { - id: '10685', - slug: 'eth-up-or-down-15m', - title: 'ETH Up or Down 15m', - recurrence: '15m', - }; - const event = createMockEvent({ series: [firstSeries, secondSeries] }); - - const result = parsePolymarketEvents([event], mockCategory); - - expect(result[0].series).toEqual(firstSeries); - }); - }); - - describe('fetchChildEventsFromGammaApi', () => { - const buildMockApiEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - series: [], - markets: [], - tags: [], - liquidity: 500000, - volume: 1000000, - ...overrides, - }); - - it('returns array of events on success', async () => { - const events = [ - buildMockApiEvent({ id: 'parent-1', title: 'Parent' }), - buildMockApiEvent({ id: 'child-1', title: 'Child' }), - ]; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(events), - }); - - const result = await fetchChildEventsFromGammaApi({ - parentEventId: 'parent-1', - }); - - expect(result).toEqual(events); - expect(result).toHaveLength(2); - }); - - it('throws on non-ok response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - json: jest.fn(), - }); - - await expect( - fetchChildEventsFromGammaApi({ parentEventId: 'parent-1' }), - ).rejects.toThrow('Failed to fetch child events'); - }); - - it('calls correct URL with parent_event_id and include_children params', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - await fetchChildEventsFromGammaApi({ parentEventId: 'abc-123' }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events?parent_event_id=abc-123&include_children=true', - ); - }); - }); - - describe('mergeChildEventsIntoParent', () => { - const buildMarket = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'cond-default', - question: 'Default question?', - description: 'Default description', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Default', - status: 'open', - volumeNum: 100, - liquidity: 50, - negRisk: false, - clobTokenIds: '["tok-a","tok-b"]', - outcomes: '["Yes","No"]', - outcomePrices: '["0.5","0.5"]', - closed: false, - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const buildEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'evt-default', - slug: 'default-event', - title: 'Default Event', - description: 'Default description', - icon: 'https://example.com/icon.png', - closed: false, - series: [], - markets: [], - tags: [], - liquidity: 500000, - volume: 1000000, - ...overrides, - }); - - it('merges parent and children markets into single event', () => { - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const childMarket1 = buildMarket({ conditionId: 'child-mkt-1' }); - const childMarket2 = buildMarket({ conditionId: 'child-mkt-2' }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - const child1 = buildEvent({ - id: 'child-1', - markets: [childMarket1], - }); - const child2 = buildEvent({ - id: 'child-2', - markets: [childMarket2], - }); - - const result = mergeChildEventsIntoParent([parent, child1, child2]); - - expect(result.markets).toHaveLength(3); - expect(result.markets[0].conditionId).toBe('parent-mkt'); - expect(result.markets[1].conditionId).toBe('child-mkt-1'); - expect(result.markets[2].conditionId).toBe('child-mkt-2'); - }); - - it('returns parent as-is when no children', () => { - const parentMarket = buildMarket({ conditionId: 'solo-mkt' }); - const parent = buildEvent({ - id: 'solo-parent', - title: 'Solo Parent', - markets: [parentMarket], - }); - - const result = mergeChildEventsIntoParent([parent]); - - expect(result).toBe(parent); - expect(result.markets).toHaveLength(1); - expect(result.markets[0].conditionId).toBe('solo-mkt'); - }); - - it('throws on empty array', () => { - expect(() => mergeChildEventsIntoParent([])).toThrow( - 'No events to merge', - ); - }); - - it('preserves parent metadata (id, slug, title)', () => { - const parent = buildEvent({ - id: 'parent-id', - slug: 'parent-slug', - title: 'Parent Title', - markets: [buildMarket()], - }); - const child = buildEvent({ - id: 'child-id', - slug: 'child-slug', - title: 'Child Title', - markets: [buildMarket({ conditionId: 'child-cond' })], - }); - - const result = mergeChildEventsIntoParent([parent, child]); - - expect(result.id).toBe('parent-id'); - expect(result.slug).toBe('parent-slug'); - expect(result.title).toBe('Parent Title'); - }); - - it('handles children with empty markets arrays', () => { - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - const childNoMarkets = buildEvent({ - id: 'child-empty', - markets: [], - }); - - const result = mergeChildEventsIntoParent([parent, childNoMarkets]); - - expect(result.markets).toHaveLength(1); - expect(result.markets[0].conditionId).toBe('parent-mkt'); - }); - - it('identifies parent by missing parentEventId when parent is not first', () => { - const childMarket = buildMarket({ conditionId: 'child-mkt' }); - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const child = buildEvent({ - id: 'child-1', - parentEventId: 'parent-1', - markets: [childMarket], - }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - - const result = mergeChildEventsIntoParent([child, parent]); - - expect(result.id).toBe('parent-1'); - expect(result.markets).toHaveLength(2); - expect(result.markets[0].conditionId).toBe('parent-mkt'); - expect(result.markets[1].conditionId).toBe('child-mkt'); - }); - - it('does not duplicate parent markets', () => { - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const childMarket = buildMarket({ conditionId: 'child-mkt' }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - const child = buildEvent({ - id: 'child-1', - markets: [childMarket], - }); - - const result = mergeChildEventsIntoParent([parent, child]); - - const parentMarketCount = result.markets.filter( - (m) => m.conditionId === 'parent-mkt', - ).length; - expect(parentMarketCount).toBe(1); - expect(result.markets).toHaveLength(2); - }); + await expect( + getIsApprovedForAll({ + tokenAddress: '0x2222222222222222222222222222222222222222', + owner: '0x1111111111111111111111111111111111111111', + operator: '0x3333333333333333333333333333333333333333', + }), + ).resolves.toBe(false); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 826713d1b29..08c7a18fc69 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -16,7 +16,6 @@ import { type PredictMarket, type PredictPosition, PredictActivity, - Result, PredictOutcome, PredictOutcomeGroup, PredictOutcomeToken, @@ -48,7 +47,7 @@ import { GROUP_ORDER, SPORTS_MARKET_TYPE_PRIORITIES, HASH_ZERO_BYTES32, - MATIC_CONTRACTS, + MATIC_CONTRACTS_V2, MSG_TO_SIGN, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, @@ -57,16 +56,12 @@ import { SLIPPAGE_SELL, SPORTS_MARKET_TYPE_TO_GROUP, } from './constants'; -import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; import { ApiKeyCreds, ClobHeaders, - ClobOrderObject, COLLATERAL_TOKEN_DECIMALS, ContractConfig, L2HeaderArgs, - OrderData, - OrderResponse, OrderSummary, PolymarketApiEvent, PolymarketApiActivity, @@ -203,39 +198,17 @@ export const getL2Headers = async ({ return headers; }; -function getClobEndpoint({ - clobVersion = 'v1', - clobBaseUrl, -}: { - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}): string { +function getClobEndpoint(): string { const { CLOB_ENDPOINT } = getPolymarketEndpoints(); - - if (clobVersion === 'v2') { - return clobBaseUrl ?? CLOB_ENDPOINT; - } - return CLOB_ENDPOINT; } -export const deriveApiKey = async ({ - address, - clobVersion = 'v1', - clobBaseUrl, -}: { - address: string; - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}) => { +export const deriveApiKey = async ({ address }: { address: string }) => { const headers = await getL1Headers({ address }); - const response = await fetch( - `${getClobEndpoint({ clobVersion, clobBaseUrl })}/auth/derive-api-key`, - { - method: 'GET', - headers, - }, - ); + const response = await fetch(`${getClobEndpoint()}/auth/derive-api-key`, { + method: 'GET', + headers, + }); if (!response.ok) { throw new Error('Failed to derive API key'); } @@ -243,26 +216,15 @@ export const deriveApiKey = async ({ return apiKeyRaw as ApiKeyCreds; }; -export const createApiKey = async ({ - address, - clobVersion = 'v1', - clobBaseUrl, -}: { - address: string; - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}) => { +export const createApiKey = async ({ address }: { address: string }) => { const headers = await getL1Headers({ address }); - const response = await fetch( - `${getClobEndpoint({ clobVersion, clobBaseUrl })}/auth/api-key`, - { - method: 'POST', - headers, - body: '', - }, - ); + const response = await fetch(`${getClobEndpoint()}/auth/api-key`, { + method: 'POST', + headers, + body: '', + }); if (response.status === 400) { - return await deriveApiKey({ address, clobVersion, clobBaseUrl }); + return await deriveApiKey({ address }); } const apiKeyRaw = await response.json(); return apiKeyRaw as ApiKeyCreds; @@ -271,17 +233,9 @@ export const createApiKey = async ({ export const priceValid = (price: number, tickSize: TickSize): boolean => price >= parseFloat(tickSize) && price <= 1 - parseFloat(tickSize); -export const getOrderBook = async ({ - tokenId, - clobVersion = 'v1', - clobBaseUrl, -}: { - tokenId: string; - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}) => { +export const getOrderBook = async ({ tokenId }: { tokenId: string }) => { const response = await fetch( - `${getClobEndpoint({ clobVersion, clobBaseUrl })}/book?token_id=${tokenId}`, + `${getClobEndpoint()}/book?token_id=${tokenId}`, { method: 'GET', }, @@ -299,121 +253,18 @@ export const getOrderBook = async ({ return responseData; }; -interface FeeRateResponse { - base_fee?: number; -} - -const DEFAULT_FEE_RATE_BPS = '0'; - -export const getFeeRateBps = async ({ - tokenId, -}: { - tokenId: string; -}): Promise => { - const { CLOB_ENDPOINT } = getPolymarketEndpoints(); - - try { - const response = await fetch( - `${CLOB_ENDPOINT}/fee-rate?token_id=${tokenId}`, - { - method: 'GET', - }, - ); - - if (!response.ok) { - let errorMessage = `Request failed with status ${response.status}`; - const responseData = (await response.json().catch(() => undefined)) as - | { error?: string } - | undefined; - if (responseData?.error) { - errorMessage = responseData.error; - } - - DevLogger.log('Polymarket fee-rate request failed, using zero fee', { - tokenId, - status: response.status, - errorMessage, - }); - return DEFAULT_FEE_RATE_BPS; - } - - const responseData = (await response.json()) as FeeRateResponse; - const baseFee = responseData.base_fee; - if ( - typeof baseFee !== 'number' || - !Number.isFinite(baseFee) || - baseFee < 0 - ) { - DevLogger.log('Polymarket fee-rate response invalid, using zero fee', { - tokenId, - baseFee, - }); - return DEFAULT_FEE_RATE_BPS; - } - - return Math.round(baseFee).toString(); - } catch (error) { - DevLogger.log('Polymarket fee-rate request threw, using zero fee', { - tokenId, - error, - }); - return DEFAULT_FEE_RATE_BPS; - } -}; - export const generateSalt = (): Hex => `0x${BigInt(Math.floor(Math.random() * 1000000)).toString(16)}`; export const getContractConfig = (chainID: number): ContractConfig => { switch (chainID) { case POLYGON_MAINNET_CHAIN_ID: - return MATIC_CONTRACTS; + return MATIC_CONTRACTS_V2; default: - throw new Error( - 'MetaMask Predict is only supported on Polygon mainnet and Amoy testnet', - ); + throw new Error('MetaMask Predict is only supported on Polygon mainnet'); } }; -export const getOrderTypedData = ({ - order, - chainId, - verifyingContract, -}: { - order: OrderData & { salt: string }; - chainId: number; - verifyingContract: string; -}) => ({ - primaryType: 'Order', - domain: { - name: 'Polymarket CTF Exchange', - version: '1', - chainId, - verifyingContract, - }, - types: { - EIP712Domain: [ - ...EIP712Domain, - { name: 'verifyingContract', type: 'address' }, - ], - Order: [ - { name: 'salt', type: 'uint256' }, - { name: 'maker', type: 'address' }, - { name: 'signer', type: 'address' }, - { name: 'taker', type: 'address' }, - { name: 'tokenId', type: 'uint256' }, - { name: 'makerAmount', type: 'uint256' }, - { name: 'takerAmount', type: 'uint256' }, - { name: 'expiration', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'feeRateBps', type: 'uint256' }, - { name: 'side', type: 'uint8' }, - { name: 'signatureType', type: 'uint8' }, - ], - }, - message: order, -}); - export const encodeApprove = ({ spender, amount, @@ -451,82 +302,6 @@ function replaceAll(s: string, search: string, replace: string) { return s.split(search).join(replace); } -export const submitClobOrder = async ({ - headers, - clobOrder, - feeAuthorization, - executor, - allowancesTx, -}: { - headers: ClobHeaders; - clobOrder: ClobOrderObject; - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; - executor?: string; - allowancesTx?: { to: string; data: string }; -}): Promise> => { - const { CLOB_RELAYER } = getPolymarketEndpoints(); - const url = `${CLOB_RELAYER}/order`; - const body: ClobOrderObject & { - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; - executor?: string; - allowancesTx?: { to: string; data: string }; - } = { - ...clobOrder, - feeAuthorization, - ...(executor && { executor }), - ...(allowancesTx && { allowancesTx }), - }; - - // For our relayer, we need to replace the underscores with dashes - // since underscores are not standardly allowed in headers - headers = { - ...headers, - ...Object.entries(headers) - .map(([key, value]) => ({ - [key.replace(/_/g, '-')]: value, - })) - .reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }; - - try { - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); - - if (response.status === 403) { - return { - success: false, - error: 'You are unable to access this provider.', - }; - } - - let responseData; - try { - responseData = (await response.json()) as OrderResponse; - } catch (error) { - responseData = undefined; - } - - if (!response.ok || !responseData || responseData?.success === false) { - const error = responseData?.errorMsg ?? response.statusText; - return { - success: false, - error, - }; - } - - return { success: true, response: responseData }; - } catch (error) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - return { - success: false, - error: `Failed to submit CLOB order: ${msg}`, - }; - } -}; - const normalizeSportsMarketType = (type: string): string => { const lower = type.toLowerCase(); if (lower.startsWith('first_half_')) { @@ -1666,6 +1441,14 @@ export const getAllowanceCalls = (params: { address: string }) => { return calls; }; +const parseNumericRpcResult = (res: string): bigint => { + if (res === '0x') { + return 0n; + } + + return BigInt(res); +}; + export const getAllowance = async ({ tokenAddress, owner, @@ -1696,8 +1479,8 @@ export const getAllowance = async ({ }, ]); - // Decode the result - const allowance = BigInt(res); + // Treat empty hex responses as zero to avoid breaking on sparse/mock RPCs. + const allowance = parseNumericRpcResult(res); return allowance; }; @@ -1732,7 +1515,7 @@ export const getIsApprovedForAll = async ({ ]); // Decode the result - convert hex to boolean - const isApproved = BigInt(res) !== 0n; + const isApproved = parseNumericRpcResult(res) !== 0n; return isApproved; }; @@ -1783,7 +1566,7 @@ export const getRawBalance = async ({ }, ]); - return BigInt(res); + return parseNumericRpcResult(res); }; export const getBalance = async ({ @@ -1939,27 +1722,15 @@ export const roundOrderAmount = ({ export const previewOrder = async ( params: Omit & { feeCollection?: PredictFeeCollection; - isV2?: boolean; - clobBaseUrl?: string; }, ): Promise => { - const { - marketId, - outcomeId, - outcomeTokenId, - side, - size, - feeCollection, - isV2, - clobBaseUrl, - } = params; + const { marketId, outcomeId, outcomeTokenId, side, size, feeCollection } = + params; const [book, feeRateBps] = await Promise.all([ getOrderBook({ tokenId: outcomeTokenId, - clobVersion: isV2 ? 'v2' : 'v1', - clobBaseUrl: isV2 ? clobBaseUrl : undefined, }), - isV2 ? Promise.resolve('0') : getFeeRateBps({ tokenId: outcomeTokenId }), + Promise.resolve('0'), ]); if (!book) { throw new Error(PREDICT_ERROR_CODES.PREVIEW_NO_ORDER_BOOK); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 316c2ca7796..3e203e2ce18 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -1,7 +1,6 @@ import { selectExtendedSportsMarketsLeagues, selectPredictBottomSheetEnabledFlag, - selectPredictClobV2EnabledFlag, selectPredictEnabledFlag, selectPredictFakOrdersEnabledFlag, selectPredictFeaturedCarouselEnabledFlag, @@ -1260,83 +1259,6 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictClobV2EnabledFlag', () => { - it('returns true when flag is enabled and version requirement is met', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictClobV2EnabledFlag(state); - - expect(result).toBe(true); - }); - - it('returns false when flag is disabled', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictClobV2: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictClobV2EnabledFlag(state); - - expect(result).toBe(false); - }); - - it('returns false when app version is below minimum required version', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '99.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictClobV2EnabledFlag(state); - - expect(result).toBe(false); - }); - - it('returns false when remote feature flags are empty', () => { - const result = selectPredictClobV2EnabledFlag(mockedEmptyFlagsState); - - expect(result).toBe(false); - }); - }); - describe('selectExtendedSportsMarketsLeagues', () => { it('returns leagues when flag is enabled and version check passes', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index e86fe246391..1d11080d892 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -147,11 +147,6 @@ export const selectPredictUpDownEnabledFlag = createSelector( (flags) => flags.predictUpDownEnabled, ); -export const selectPredictClobV2EnabledFlag = createSelector( - selectPredictFeatureFlags, - (flags) => flags.predictClobV2Enabled, -); - export const selectPredictFeaturedCarouselEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index d1738a446aa..dd8ff6632d8 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -23,10 +23,6 @@ export interface PredictExtendedSportsMarketsFlag leagues: string[]; } -export type PredictClobV2Flag = VersionGatedFeatureFlag; - -export type PredictClobV2UseLegacyClobHostFlag = VersionGatedFeatureFlag; - export interface PredictFeatureFlags { feeCollection: PredictFeeCollection; liveSportsLeagues: string[]; @@ -35,8 +31,6 @@ export interface PredictFeatureFlags { fakOrdersEnabled: boolean; predictWithAnyTokenEnabled: boolean; predictUpDownEnabled: boolean; - predictClobV2Enabled: boolean; - predictClobV2ClobBaseUrl?: string; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 001f3832640..39970b2c497 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -183,7 +183,8 @@ export type PredictSportsLeague = | 'bol1' | 'itc' | 'dfb' - | 'cde'; + | 'cde' + | 'fifwc'; // Game status export type PredictGameStatus = 'scheduled' | 'ongoing' | 'ended'; @@ -614,7 +615,6 @@ export interface PreviewOrderParams { export interface AccountState { address: Hex; isDeployed: boolean; - hasAllowances: boolean; } export interface GeoBlockResponse { diff --git a/app/components/UI/Predict/utils/gameParser.ts b/app/components/UI/Predict/utils/gameParser.ts index 7d7c4860768..9a3c50d53a2 100644 --- a/app/components/UI/Predict/utils/gameParser.ts +++ b/app/components/UI/Predict/utils/gameParser.ts @@ -207,6 +207,11 @@ const LEAGUE_SLUG_CONFIGS: Record = { teamOrder: 'home-away', tagSlug: 'coupe-de-france', }, + fifwc: { + pattern: /^fifwc-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'fifa-world-cup', + }, }; export type TeamLookup = ( diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts index 55dc7afac71..f624a78b99a 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts @@ -4,7 +4,6 @@ import { DEFAULT_FEE_COLLECTION_FLAG, DEFAULT_MARKET_HIGHLIGHTS_FLAG, } from '../constants/flags'; -import { LEGACY_V2_CLOB_BASE_URL } from '../providers/polymarket/constants'; import { resolvePredictFeatureFlags } from './resolvePredictFeatureFlags'; jest.mock('../../../../util/remoteFeatureFlag', () => ({ @@ -32,8 +31,6 @@ describe('resolvePredictFeatureFlags', () => { fakOrdersEnabled: false, predictWithAnyTokenEnabled: false, predictUpDownEnabled: false, - predictClobV2Enabled: false, - predictClobV2ClobBaseUrl: undefined, }); }); @@ -188,129 +185,6 @@ describe('resolvePredictFeatureFlags', () => { expect(result.fakOrdersEnabled).toBe(true); expect(result.predictWithAnyTokenEnabled).toBe(false); - expect(result.predictClobV2Enabled).toBe(false); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - describe('predictClobV2Enabled', () => { - const mockEnabledVersionGatedFlags = () => { - mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => - Boolean( - flag && - typeof flag === 'object' && - 'enabled' in flag && - (flag as { enabled: boolean }).enabled, - ), - ); - }; - - it('returns false when flag is missing', () => { - const result = resolvePredictFeatureFlags({}); - - expect(result.predictClobV2Enabled).toBe(false); - }); - - it('returns true when flag is enabled and version validation passes', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - it('uses the temporary v2 CLOB host when the legacy-host flag is also enabled', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBe(LEGACY_V2_CLOB_BASE_URL); - }); - - it('keeps the canonical v2 CLOB host when the legacy-host flag is disabled', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - it('ignores the legacy-host flag when predictClobV2 is disabled or version-gated off', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: false, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(false); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - it('supports enabling v2 locally while the internal legacy-host flag remains remote', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: false, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - localOverrides: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBe(LEGACY_V2_CLOB_BASE_URL); - }); }); describe('extendedSportsMarketsLeagues', () => { diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts index 533d8ed9398..07d12182ade 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts @@ -16,7 +16,6 @@ import { PredictLiveSportsFlag, PredictMarketHighlightsFlag, } from '../types/flags'; -import { LEGACY_V2_CLOB_BASE_URL } from '../providers/polymarket/constants'; import { unwrapRemoteFeatureFlag } from './flags'; export interface RawFeatureFlags { @@ -32,32 +31,6 @@ function resolveVersionGatedBooleanFlag(flag: unknown): boolean { ); } -function resolvePredictClobV2Flag({ - predictClobV2Flag, - predictClobV2UseLegacyClobHostFlag, -}: { - predictClobV2Flag: unknown; - predictClobV2UseLegacyClobHostFlag: unknown; -}): { - enabled: boolean; - clobBaseUrl?: string; -} { - const enabled = resolveVersionGatedBooleanFlag(predictClobV2Flag); - - if (!enabled) { - return { enabled: false, clobBaseUrl: undefined }; - } - - return { - enabled: true, - clobBaseUrl: resolveVersionGatedBooleanFlag( - predictClobV2UseLegacyClobHostFlag, - ) - ? LEGACY_V2_CLOB_BASE_URL - : undefined, - }; -} - /** * Resolves the Predict feature flags used by both the controller and selectors. * Local overrides take precedence over remote values when both are present. @@ -118,10 +91,6 @@ export function resolvePredictFeatureFlags( const predictUpDownEnabled = resolveVersionGatedBooleanFlag( flags.predictUpDown, ); - const predictClobV2 = resolvePredictClobV2Flag({ - predictClobV2Flag: flags.predictClobV2, - predictClobV2UseLegacyClobHostFlag: flags.predictClobV2UseLegacyClobHost, - }); return { feeCollection, @@ -131,7 +100,5 @@ export function resolvePredictFeatureFlags( fakOrdersEnabled, predictWithAnyTokenEnabled, predictUpDownEnabled, - predictClobV2Enabled: predictClobV2.enabled, - predictClobV2ClobBaseUrl: predictClobV2.clobBaseUrl, }; } diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx index 687731d98e2..747385940d2 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx @@ -79,11 +79,11 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ })); jest.mock('../../../../../../Views/confirmations/constants/predict', () => ({ - POLYGON_USDCE: { - address: '0xUSDCe', + POLYGON_PUSD: { + address: '0xPUSD', decimals: 6, - name: 'USDC.e', - symbol: 'USDC.e', + name: 'Polymarket USD', + symbol: 'pUSD', }, })); @@ -139,12 +139,12 @@ describe('PredictPayWithRow', () => { expect(screen.getByTestId('token-icon-0xToken-0x89')).toBeOnTheScreen(); }); - it('renders TokenIcon with POLYGON_USDCE when predict balance selected', () => { + it('renders TokenIcon with POLYGON_PUSD when predict balance selected', () => { mockIsPredictBalanceSelected = true; renderWithProvider(); - expect(screen.getByTestId('token-icon-0xUSDCe-0x89')).toBeOnTheScreen(); + expect(screen.getByTestId('token-icon-0xPUSD-0x89')).toBeOnTheScreen(); }); it('does not render TokenIcon when payToken has no address', () => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx index 540e0ff1666..929956cc78b 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx @@ -27,7 +27,7 @@ import { TokenIconVariant, } from '../../../../../../Views/confirmations/components/token-icon'; import { isHardwareAccount } from '../../../../../../../util/address'; -import { POLYGON_USDCE } from '../../../../../../Views/confirmations/constants/predict'; +import { POLYGON_PUSD } from '../../../../../../Views/confirmations/constants/predict'; import { usePredictPaymentToken } from '../../../../hooks/usePredictPaymentToken'; import { PREDICT_BALANCE_CHAIN_ID } from '../../../../constants/transactions'; import { usePredictDefaultPaymentToken } from '../../hooks/usePredictDefaultPaymentToken'; @@ -74,7 +74,7 @@ export function PredictPayWithRow({ ? 'Predict balance' : (selectedPaymentToken?.symbol ?? payToken?.symbol ?? ''); const tokenIconAddress = showPredictBalance - ? POLYGON_USDCE.address + ? POLYGON_PUSD.address : (payToken?.address as Hex | undefined); const tokenIconChainId = showPredictBalance ? PREDICT_BALANCE_CHAIN_ID diff --git a/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx index 50e77097146..90700c27a10 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx @@ -608,18 +608,8 @@ describe('OndoCampaignRwaSelectorView', () => { }); }); - describe('token name sanitization', () => { - it('strips "Ondo Tokenized " prefix from token names in list rows', () => { - const token = { ...buildToken('AAPL'), name: 'Ondo Tokenized Apple' }; - mockUseRwaTokens.mockReturnValue({ data: [token], isLoading: false }); - const { getByText, queryByText } = render( - , - ); - expect(getByText('Apple')).toBeDefined(); - expect(queryByText('Ondo Tokenized Apple')).toBeNull(); - }); - - it('strips "(Ondo Tokenized)" suffix from token names in list rows', () => { + describe('Ondo token name display', () => { + it('preserves backend-provided suffix names in list rows', () => { const token = { ...buildToken('AAPL'), name: 'Apple (Ondo Tokenized)', @@ -628,25 +618,21 @@ describe('OndoCampaignRwaSelectorView', () => { const { getByText, queryByText } = render( , ); - expect(getByText('Apple')).toBeDefined(); - expect(queryByText('Apple (Ondo Tokenized)')).toBeNull(); - }); - - it('leaves unrelated token names unchanged', () => { - const token = { ...buildToken('USDY'), name: 'Ondo USD Yield' }; - mockUseRwaTokens.mockReturnValue({ data: [token], isLoading: false }); - const { getByText } = render(); - expect(getByText('Ondo USD Yield')).toBeDefined(); + expect(getByText('Apple (Ondo Tokenized)')).toBeDefined(); + expect(queryByText('Apple')).toBeNull(); }); - it('passes original unsanitized name to goToSwaps when token has Ondo prefix', () => { - const token = { ...buildToken('AAPL'), name: 'Ondo Tokenized Apple' }; + it('passes the backend-provided name to goToSwaps', () => { + const token = { + ...buildToken('AAPL'), + name: 'Apple (Ondo Tokenized)', + }; mockUseRwaTokens.mockReturnValue({ data: [token], isLoading: false }); const { getByTestId } = render(); fireEvent.press(getByTestId('token-row-AAPL')); expect(mockGoToSwaps).toHaveBeenCalledWith( undefined, - expect.objectContaining({ name: 'Ondo Tokenized Apple' }), + expect.objectContaining({ name: 'Apple (Ondo Tokenized)' }), ); }); }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.tsx b/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.tsx index 7e35da31851..97ee6326fb7 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.tsx @@ -36,11 +36,7 @@ import ErrorBoundary from '../../../Views/ErrorBoundary'; import { useRwaTokens } from '../../Trending/hooks/useRwaTokens/useRwaTokens'; import TrendingTokenRowItem from '../../Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; import { getTrendingTokenImageUrl } from '../../Trending/utils/getTrendingTokenImageUrl'; -import { - parseCaip19, - caipChainIdToHex, - sanitizeOndoTokenName, -} from '../utils/formatUtils'; +import { parseCaip19, caipChainIdToHex } from '../utils/formatUtils'; import { RWA_NETWORKS_LIST } from '../../Trending/utils/trendingNetworksList'; import { useSwapBridgeNavigation, @@ -262,30 +258,27 @@ const OndoCampaignRwaSelectorView: React.FC = () => { sourceToken: srcBridgeToken, }); - // Deduplicate by assetId and sanitize display names. + // Deduplicate by assetId while preserving backend-provided display names. // Use CAIP-19 assetId (not symbol) for deduplication — symbol comparison // is fragile when casing differs between chains. const tokens = useMemo((): TrendingAsset[] => { const seen = new Set(); - return rwaTokens - .filter((token) => { - if (srcTokenAsset && token.assetId === srcTokenAsset) return false; - if (seen.has(token.assetId)) return false; - seen.add(token.assetId); - return true; - }) - .map((token) => ({ ...token, name: sanitizeOndoTokenName(token.name) })); + return rwaTokens.filter((token) => { + if (srcTokenAsset && token.assetId === srcTokenAsset) return false; + if (seen.has(token.assetId)) return false; + seen.add(token.assetId); + return true; + }); }, [rwaTokens, srcTokenAsset]); const handleAssetSelect = useCallback( (asset: TrendingAsset) => { const parsed = parseCaip19(asset.assetId); if (!parsed) return; - const rawToken = rwaTokens.find((t) => t.assetId === asset.assetId); const destToken: BridgeToken = { address: parsed.assetReference, symbol: asset.symbol, - name: rawToken?.name ?? asset.name, + name: asset.name, decimals: asset.decimals, chainId: `${parsed.namespace}:${parsed.chainId}` as CaipChainId, image: getTrendingTokenImageUrl(asset.assetId), @@ -318,7 +311,6 @@ const OndoCampaignRwaSelectorView: React.FC = () => { trackEvent, createEventBuilder, ondoUsdSrcToken, - rwaTokens, ], ); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx index ce235b5c58b..6db8aef4c69 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx @@ -142,7 +142,6 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Please try again', 'rewards.ondo_campaign_portfolio.retry': 'Retry', 'rewards.ondo_campaign_portfolio.updated_at': `Updated: ${params?.time ?? ''}`, - 'rewards.ondo_campaign_portfolio.position_units': `${params?.units ?? ''} units`, }; return translations[key] ?? key; }, @@ -217,10 +216,11 @@ jest.mock( ); const mockRefetch = jest.fn(); +const MOCK_POSITION_DISPLAY_NAME = 'Apple Inc. (Ondo Tokenized)'; const MOCK_POSITION: OndoGmPortfolioPositionDto = { tokenSymbol: 'AAPLon', - tokenName: 'Apple Inc.', + tokenName: MOCK_POSITION_DISPLAY_NAME, tokenAsset: 'eip155:1/erc20:0x14c3abf95cb9c93a8b82c1cdcb76d72cb87b2d4c', units: '45.2', bookPrice: '200.000000', @@ -369,7 +369,7 @@ describe('OndoPortfolio', () => { it('renders the token name', () => { const { getByText } = render(); - expect(getByText('Apple Inc.')).toBeDefined(); + expect(getByText(MOCK_POSITION_DISPLAY_NAME)).toBeDefined(); }); }); @@ -395,8 +395,8 @@ describe('OndoPortfolio', () => { it('pressing a position row does not throw', () => { const { getByText } = render(); - fireEvent.press(getByText('Apple Inc.')); - expect(getByText('Apple Inc.')).toBeDefined(); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); + expect(getByText(MOCK_POSITION_DISPLAY_NAME)).toBeDefined(); }); it('renders empty banner when portfolio has no positions', () => { @@ -480,7 +480,7 @@ describe('OndoPortfolio', () => { const props = buildPropsWithBalance(rawHexBalance); const { getByText } = render(); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); expect(props.onOpenAccountPicker).not.toHaveBeenCalled(); }, @@ -493,7 +493,9 @@ describe('OndoPortfolio', () => { // empty and the component navigates directly (length === 0 branch). // Picker not opened either way — we just confirm no throw. const { getByText } = render(); - expect(() => fireEvent.press(getByText('Apple Inc.'))).not.toThrow(); + expect(() => + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)), + ).not.toThrow(); }); }); @@ -503,9 +505,9 @@ describe('OndoPortfolio', () => { portfolio: MOCK_PORTFOLIO, }; - it('renders the units text', () => { + it('renders units with the uppercased ticker', () => { const { getByText } = render(); - expect(getByText('45.2 units')).toBeDefined(); + expect(getByText('45.2 AAPLON')).toBeDefined(); }); it('renders positive PnL percent in green', () => { @@ -572,7 +574,7 @@ describe('OndoPortfolio', () => { onOpenAccountPicker={onOpenAccountPicker} />, ); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); expect(onOpenAccountPicker).not.toHaveBeenCalled(); }); }); @@ -673,7 +675,7 @@ describe('OndoPortfolio', () => { />, ); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); expect(onOpenAccountPicker).toHaveBeenCalledTimes(1); const config = (onOpenAccountPicker as jest.Mock).mock.calls[0][0]; @@ -726,7 +728,7 @@ describe('OndoPortfolio', () => { />, ); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); expect(onOpenAccountPicker).not.toHaveBeenCalled(); }); @@ -788,7 +790,7 @@ describe('OndoPortfolio', () => { />, ); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); // Account must be found despite key case mismatch → picker opened expect(onOpenAccountPicker).toHaveBeenCalledTimes(1); @@ -850,7 +852,7 @@ describe('OndoPortfolio', () => { />, ); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); expect(onOpenAccountPicker).toHaveBeenCalledTimes(1); const config = (onOpenAccountPicker as jest.Mock).mock.calls[0][0]; @@ -908,7 +910,7 @@ describe('OndoPortfolio', () => { />, ); - fireEvent.press(getByText('Apple Inc.')); + fireEvent.press(getByText(MOCK_POSITION_DISPLAY_NAME)); // No subscribed account has balance → navigate directly, picker not opened expect(onOpenAccountPicker).not.toHaveBeenCalled(); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx index 277544cb77d..05e19a98df4 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx @@ -41,7 +41,6 @@ import { groupPortfolioPositionsByAsset, formatPnlPercent, isPnlNonNegative, - sanitizeOndoTokenName, } from './OndoPortfolio.utils'; import { selectAllTokenBalances } from '../../../../../selectors/tokenBalancesController'; import { selectAllTokens } from '../../../../../selectors/tokensController'; @@ -458,15 +457,20 @@ const OndoPortfolio: React.FC = ({ justifyContent={BoxJustifyContent.Between} alignItems={BoxAlignItems.Center} > + + + {row.tokenName} + + - {sanitizeOndoTokenName(row.tokenName)} - - {formatUsd(row.currentValue)} @@ -480,12 +484,7 @@ const OndoPortfolio: React.FC = ({ variant={TextVariant.BodySm} color={TextColor.TextAlternative} > - {strings( - 'rewards.ondo_campaign_portfolio.position_units', - { - units: row.units, - }, - )} + {`${row.units} ${row.tokenSymbol.toUpperCase()}`} {rowPnlPercent ? ( diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts index 4fe19f79e43..7b7bec74676 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts @@ -2,7 +2,6 @@ import { groupPortfolioPositionsByAsset, formatPnlPercent, isPnlNonNegative, - sanitizeOndoTokenName, } from './OndoPortfolio.utils'; describe('groupPortfolioPositionsByAsset', () => { @@ -112,45 +111,3 @@ describe('isPnlNonNegative', () => { expect(isPnlNonNegative('—')).toBe(false); }); }); - -describe('sanitizeOndoTokenName', () => { - it('strips "(Ondo Tokenized)" suffix and trims', () => { - expect(sanitizeOndoTokenName('US Dollar (Ondo Tokenized)')).toBe( - 'US Dollar', - ); - }); - - it('strips "Ondo Tokenized " prefix (trending token API format)', () => { - expect(sanitizeOndoTokenName('Ondo Tokenized Apple')).toBe('Apple'); - }); - - it('is case-insensitive', () => { - expect(sanitizeOndoTokenName('Token (ondo tokenized)')).toBe('Token'); - }); - - it('truncates to 28 characters with ellipsis', () => { - expect(sanitizeOndoTokenName('A Very Long Token Name That Exceeds')).toBe( - 'A Very Long Token Name That...', - ); - }); - - it('strips then truncates with ellipsis', () => { - const long = 'Extremely Long Name Here That Keeps Going (Ondo Tokenized)'; - const result = sanitizeOndoTokenName(long); - expect(result).toBe('Extremely Long Name Here Tha...'); - }); - - it('does not add ellipsis when exactly 28 characters', () => { - expect(sanitizeOndoTokenName('1234567890123456789012345678')).toBe( - '1234567890123456789012345678', - ); - }); - - it('returns the name unchanged when no stripping or truncation is needed', () => { - expect(sanitizeOndoTokenName('OUSG')).toBe('OUSG'); - }); - - it('handles empty string', () => { - expect(sanitizeOndoTokenName('')).toBe(''); - }); -}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts index a1e6008b85d..40d4b601877 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts @@ -8,7 +8,6 @@ export { getChainHex, shortenAddress, getAssetReference, - sanitizeOndoTokenName, } from '../../utils/formatUtils'; /** diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts index 8424cb81242..c78001af9c0 100644 --- a/app/components/UI/Rewards/utils/formatUtils.test.ts +++ b/app/components/UI/Rewards/utils/formatUtils.test.ts @@ -25,7 +25,6 @@ import { formatUsd, formatSignedUsd, formatCompactUsd, - sanitizeOndoTokenName, formatOrdinalRank, } from './formatUtils'; import { IconName } from '@metamask/design-system-react-native'; @@ -1654,59 +1653,4 @@ describe('formatUtils', () => { expect(formatCompactUsd(-75_000)).toBe('-$75K'); }); }); - - describe('sanitizeOndoTokenName', () => { - it('strips "(Ondo Tokenized)" suffix and trims', () => { - expect(sanitizeOndoTokenName('US Dollar (Ondo Tokenized)')).toBe( - 'US Dollar', - ); - }); - - it('strips "Ondo Tokenized " prefix (trending token API format)', () => { - expect(sanitizeOndoTokenName('Ondo Tokenized Apple')).toBe('Apple'); - }); - - it('is case-insensitive for suffix form', () => { - expect(sanitizeOndoTokenName('Token (ondo tokenized)')).toBe('Token'); - }); - - it('is case-insensitive for prefix form', () => { - expect(sanitizeOndoTokenName('ONDO TOKENIZED Apple')).toBe('Apple'); - }); - - it('truncates to 28 characters with ellipsis', () => { - expect(sanitizeOndoTokenName('A Very Long Token Name That Exceeds')).toBe( - 'A Very Long Token Name That...', - ); - }); - - it('strips suffix then truncates with ellipsis', () => { - const long = 'Extremely Long Name Here That Keeps Going (Ondo Tokenized)'; - expect(sanitizeOndoTokenName(long)).toBe( - 'Extremely Long Name Here Tha...', - ); - }); - - it('strips prefix then truncates with ellipsis', () => { - expect( - sanitizeOndoTokenName( - 'Ondo Tokenized Extremely Long Name That Exceeds', - ), - ).toBe('Extremely Long Name That Exc...'); - }); - - it('does not add ellipsis when exactly 28 characters', () => { - expect(sanitizeOndoTokenName('1234567890123456789012345678')).toBe( - '1234567890123456789012345678', - ); - }); - - it('leaves unrelated names unchanged', () => { - expect(sanitizeOndoTokenName('OUSG')).toBe('OUSG'); - }); - - it('returns empty string for an empty input', () => { - expect(sanitizeOndoTokenName('')).toBe(''); - }); - }); }); diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts index 19eaf683800..d6de04f929a 100644 --- a/app/components/UI/Rewards/utils/formatUtils.ts +++ b/app/components/UI/Rewards/utils/formatUtils.ts @@ -490,23 +490,6 @@ export const shortenAddress = (address: string): string => { return `${address.slice(0, 6)}...${address.slice(-4)}`; }; -const MAX_ONDO_TOKEN_NAME_LENGTH = 28; - -/** - * Strips Ondo branding from a token name and truncates to - * MAX_ONDO_TOKEN_NAME_LENGTH characters with an ellipsis if needed. - * - * Handles two forms: prefix ("Ondo Tokenized Apple" → "Apple") and - * suffix ("US Dollar (Ondo Tokenized)" → "US Dollar"). - */ -export function sanitizeOndoTokenName(raw: string): string { - const cleaned = raw - .replace(/(?:^ondo\s+tokenized\s+|\s*\(ondo\s+tokenized\))/gi, '') - .trim(); - if (cleaned.length <= MAX_ONDO_TOKEN_NAME_LENGTH) return cleaned; - return `${cleaned.slice(0, MAX_ONDO_TOKEN_NAME_LENGTH).trim()}...`; -} - export function getPortfolioReturnColor( portfolioPnlPercent: string | undefined, ): TextColor { diff --git a/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx b/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx index be8f03f8509..85471e4f3b8 100644 --- a/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx +++ b/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Platform, Switch, View } from 'react-native'; +import { Switch, View } from 'react-native'; import { createStyles } from './styles'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import Text, { TextColor, TextVariant, @@ -57,7 +56,7 @@ const SecurityOptionToggle = ({ style={styles.switch} ios_backgroundColor={colors.border.muted} disabled={disabled} - {...generateTestId(Platform, testId)} + testID={testId} /> diff --git a/app/components/UI/SettingsDrawer/index.js b/app/components/UI/SettingsDrawer/index.js index ba16c8ad17c..57d57bf854c 100644 --- a/app/components/UI/SettingsDrawer/index.js +++ b/app/components/UI/SettingsDrawer/index.js @@ -1,9 +1,8 @@ import React from 'react'; -import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; import PropTypes from 'prop-types'; import { fontStyles } from '../../../styles/common'; import { useTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import Icon, { IconColor, IconName, @@ -54,10 +53,6 @@ const propTypes = { * Additional descriptive text about this option */ description: PropTypes.string, - /** - * Disable bottom border - */ - noBorder: PropTypes.bool, /** * Handler called when this drawer is pressed */ @@ -96,7 +91,7 @@ const SettingsDrawer = ({ const { colors } = useTheme(); const styles = createStyles(colors, titleColor); return ( - + diff --git a/app/components/UI/SkipAccountSecurityModal/index.js b/app/components/UI/SkipAccountSecurityModal/index.js index a46800d47a5..77391bebb7b 100644 --- a/app/components/UI/SkipAccountSecurityModal/index.js +++ b/app/components/UI/SkipAccountSecurityModal/index.js @@ -11,7 +11,6 @@ import Text, { } from '../../../component-library/components/Texts/Text'; import PropTypes from 'prop-types'; import { useTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import { SkipAccountSecurityModalSelectorsIDs } from './SkipAccountSecurityModal.testIds'; import BottomSheet from '../../../component-library/components/BottomSheets/BottomSheet'; import Checkbox from '../../../component-library/components/Checkbox'; @@ -102,7 +101,7 @@ const SkipAccountSecurityModal = ({ route }) => { name={IconName.Danger} size={IconSize.Lg} style={styles.imageWarning} - {...generateTestId(Platform, 'skip-backup-warning')} + testID="skip-backup-warning" /> diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 2851f3f6246..71d10b709cb 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -44,8 +44,15 @@ import Balance from '../../AssetOverview/Balance'; import TokenDetails from '../../AssetOverview/TokenDetails'; import { TokenDetailsActions } from './TokenDetailsActions'; import AssetOverviewClaimBonus from '../../Earn/components/AssetOverviewClaimBonus'; +import MoneyConvertStablecoins from '../../Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins'; +import { MONEY_EVENTS_CONSTANTS } from '../../Money/constants/moneyEvents'; import { isTokenEligibleForMerklRewards } from '../../Earn/components/MerklRewards/hooks/useMerklRewards'; -import { selectMerklCampaignClaimingEnabledFlag } from '../../Earn/selectors/featureFlags'; +import { isMusdToken } from '../../Earn/constants/musd'; +import { + selectIsMusdConversionFlowEnabledFlag, + selectMerklCampaignClaimingEnabledFlag, +} from '../../Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../Earn/hooks/useMusdConversionEligibility'; import PerpsDiscoveryBanner from '../../Perps/components/PerpsDiscoveryBanner'; import { isTokenTrustworthyForPerps } from '../../Perps/constants/perpsConfig'; import { selectTokenOverviewAdvancedChartEnabled } from '../../../../selectors/featureFlagController/tokenOverviewAdvancedChart'; @@ -341,6 +348,15 @@ const AssetOverviewContent: React.FC = ({ [isMerklClaimingEnabled, token.chainId, token.address], ); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); + const showMusdConvertSection = + isMusdToken(token.address) && + isMusdConversionFlowEnabled && + isMusdGeoEligible; + const securityConfig = useMemo( () => getResultTypeConfig(securityData?.resultType), [securityData?.resultType], @@ -748,6 +764,11 @@ const AssetOverviewContent: React.FC = ({ {isTokenEligibleForMerklClaim && ( )} + {showMusdConvertSection && ( + + )} { ///: BEGIN:ONLY_INCLUDE_IF(tron) tronNativeToken && ( diff --git a/app/components/UI/Tokens/TokenList/TokenList.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx index 584dc59f229..84ab6d4a48d 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -47,6 +47,11 @@ interface TokenListProps { * refresh orchestrator (e.g. Money Hub). */ refreshControl?: React.ReactElement; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } const TokenListComponent = ({ @@ -60,6 +65,7 @@ const TokenListComponent = ({ isFullView = false, listFooterComponent, refreshControl, + hideSecondaryPriceRow = false, }: TokenListProps) => { const { colors } = useTheme(); const tw = useTailwind(); @@ -155,6 +161,7 @@ const TokenListComponent = ({ showPercentageChange={showPercentageChange} isFullView={isFullView} shouldShowTokenListItemCta={shouldShowTokenListItemCta} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ), [ @@ -164,6 +171,7 @@ const TokenListComponent = ({ showPercentageChange, isFullView, shouldShowTokenListItemCta, + hideSecondaryPriceRow, ], ); @@ -182,6 +190,7 @@ const TokenListComponent = ({ showPercentageChange={showPercentageChange} isFullView={isFullView} shouldShowTokenListItemCta={shouldShowTokenListItemCta} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ))} {shouldShowViewAllButton && ( diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 8b4d871efd1..5b5f1a5e7e2 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1138,6 +1138,56 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }); }); + describe('hideSecondaryPriceRow (Money Hub compact mUSD layout)', () => { + const musdAsset = { + ...defaultAsset, + address: MUSD_TOKEN_ADDRESS, + symbol: 'mUSD', + name: 'MetaMask USD', + isNative: false, + balance: '1280.34', + balanceFiat: '$1,280.34', + }; + const musdKey: FlashListAssetKey = { + address: MUSD_TOKEN_ADDRESS, + chainId: '0x1', + isStaked: false, + }; + const renderCompact = (key: FlashListAssetKey) => + renderWithProvider( + , + ); + + it('renders compact mUSD layout and navigates on press', () => { + prepareMocks({ asset: musdAsset }); + const { getByText } = renderCompact(musdKey); + expect(getByText('MetaMask USD')).toBeOnTheScreen(); + expect(getByText('1280.34 mUSD')).toBeOnTheScreen(); + fireEvent.press(getByText('MetaMask USD')); + expect(mockNavigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ symbol: 'mUSD' }), + ); + }); + + it('does not affect non-mUSD rows', () => { + prepareMocks({ asset: defaultAsset }); + const { getByText } = renderCompact({ + address: '0x456', + chainId: '0x1', + isStaked: false, + }); + expect(getByText('Test Token')).toBeOnTheScreen(); + }); + }); + describe('mUSD Bonus Row', () => { const claimableAsset = { ...defaultAsset, @@ -1151,14 +1201,14 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isStaked: false, }; - it('shows green "3% bonus" on mUSD rows when conversion is enabled', () => { + it('does not render the "3% bonus" label on mUSD rows (MUSD-729)', () => { prepareMocks({ asset: claimableAsset, pricePercentChange1d: 5.0, isMusdConversionEnabled: true, }); - const { getByText, queryByText } = renderWithProvider( + const { queryByText, getByText } = renderWithProvider( { ); expect( - getByText( + queryByText( strings('earn.musd_conversion.percentage_bonus', { percentage: MUSD_CONVERSION_APY, }), ), - ).toBeOnTheScreen(); - expect(queryByText('+5.00%')).toBeNull(); - // Price rail must stay hidden on mUSD bonus rows per Figma. - expect(queryByText(/\u2022/)).toBeNull(); + ).toBeNull(); + // Without the bonus label or a Convert CTA, the row falls back to the + // standard percentage-change rail. + expect(getByText('+5.00%')).toBeOnTheScreen(); }); it('shows normal percentage when mUSD but conversion flow is disabled', () => { diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 8fe0f6230a8..1f1dd585081 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { CaipAssetType, Hex } from '@metamask/utils'; import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useMemo } from 'react'; -import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import Badge, { BadgeVariant, @@ -21,11 +21,7 @@ import { TokenI } from '../../types'; import { ScamWarningIcon } from './ScamWarningIcon/ScamWarningIcon'; import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; import { FlashListAssetKey } from '../TokenList'; -import { - selectIsMusdConversionFlowEnabledFlag, - selectStablecoinLendingEnabledFlag, -} from '../../../Earn/selectors/featureFlags'; -import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; +import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { selectAsset } from '../../../../../selectors/assets/assets-list'; import Tag from '../../../../../component-library/components/Tags/Tag'; @@ -76,8 +72,7 @@ import { } from '@metamask/assets-controllers'; import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; import { safeToChecksumAddress } from '../../../../../util/address'; -import generateTestId from '../../../../../../wdio/utils/generateTestId'; -import { getAssetTestId } from '../../../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; +import { getAssetTestId } from '../../../../../../tests/selectors/Wallet/WalletView.selectors'; import SkeletonText from '../../../Ramp/Aggregator/components/SkeletonText'; import { TOKEN_BALANCE_LOADING, @@ -91,6 +86,7 @@ import { } from '../../../AssetElement/index.constants'; import { Box, + BoxAlignItems, BoxFlexDirection, BoxJustifyContent, FontWeight, @@ -154,6 +150,11 @@ interface TokenListItemProps { showPercentageChange?: boolean; isFullView?: boolean; shouldShowTokenListItemCta: (asset?: TokenI) => boolean; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } export const TokenListItem = React.memo( @@ -165,6 +166,7 @@ export const TokenListItem = React.memo( showPercentageChange = true, isFullView = false, shouldShowTokenListItemCta, + hideSecondaryPriceRow = false, }: TokenListItemProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); @@ -248,11 +250,6 @@ export const TokenListItem = React.memo( selectStablecoinLendingEnabledFlag, ); - const isMusdConversionFlowEnabled = useSelector( - selectIsMusdConversionFlowEnabledFlag, - ); - const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); - const { getEarnToken } = useEarnTokens(); const earnToken = getEarnToken(asset as TokenI); @@ -266,8 +263,6 @@ export const TokenListItem = React.memo( ); const isMusdAsset = !!asset && isMusdToken(asset.address); - const showMusdBonusRow = - isMusdAsset && isMusdConversionFlowEnabled && isMusdGeoEligible; const pricePercentChange1d = useTokenPricePercentageChange(asset); @@ -441,16 +436,6 @@ export const TokenListItem = React.memo( }); const secondaryBalanceDisplay = useMemo(() => { - if (showMusdBonusRow) { - return { - text: strings('earn.musd_conversion.percentage_bonus', { - percentage: MUSD_CONVERSION_APY, - }), - color: CLTextColor.Success, - onPress: undefined, - }; - } - if (shouldShowConvertToMusdCta) { return { text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', { @@ -493,7 +478,6 @@ export const TokenListItem = React.memo( return { text, color, onPress: undefined }; }, [ - showMusdBonusRow, shouldShowConvertToMusdCta, isStablecoinLendingEnabled, earnToken?.experience?.type, @@ -552,6 +536,68 @@ export const TokenListItem = React.memo( fiatBalanceDisplay = fiatBalance; } + // Money Hub compact mUSD layout: name vertically centered, fiat over + // native on the right, no price/24h-change row. + if (hideSecondaryPriceRow && isMusdAsset) { + return ( + onItemPress?.(asset)} + style={styles.itemWrapper} + testID={getAssetTestId(asset.symbol)} + > + + ) + } + > + + + + + {asset.name || asset.symbol} + + + + {fiatBalanceDisplay} + + + {tokenBalance} + + + + + ); + } + return ( { @@ -563,7 +609,7 @@ export const TokenListItem = React.memo( onLongPress?.(asset); }} style={styles.itemWrapper} - {...generateTestId(Platform, getAssetTestId(asset.symbol))} + testID={getAssetTestId(asset.symbol)} > {/* Column: 1 - Token logo */} - {showMusdBonusRow ? ( - <> - - - {tokenBalance} - - - - + + {tokenPriceInFiat && !hideFiatForScamWarning + ? formatPriceWithSubscriptNotation( + tokenPriceInFiat, + currentCurrency, + ) + : '-'} + {' \u2022 '} + + + {hideFiatForScamWarning ? ( + + {'-'} + + ) : ( + - {secondaryBalanceDisplay.text} - - - ) : ( - <> - {/* Token price and percentage change */} - - - {tokenPriceInFiat && !hideFiatForScamWarning - ? formatPriceWithSubscriptNotation( - tokenPriceInFiat, - currentCurrency, - ) - : '-'} - {' \u2022 '} - - - {hideFiatForScamWarning ? ( - - {'-'} - - ) : ( - - - {secondaryBalanceDisplay.text || '-'} - - - )} - - - {/* Token balance */} - - {tokenBalance} + {secondaryBalanceDisplay.text || '-'} - - - )} + + )} + + + {/* Token balance */} + + + {tokenBalance} + + diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 9446ad1f1d1..8782c01dfbe 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -69,6 +69,11 @@ interface TokensProps { * already handles its own loading state (e.g. CashTokensFullView). */ hideLoadingSkeleton?: boolean; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } const Tokens = forwardRef( @@ -80,6 +85,7 @@ const Tokens = forwardRef( listFooterComponent, refreshControl, hideLoadingSkeleton = false, + hideSecondaryPriceRow = false, }, ref, ) => { @@ -271,6 +277,7 @@ const Tokens = forwardRef( isFullView={isFullView} listFooterComponent={listFooterComponent} refreshControl={refreshControl} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ); @@ -278,9 +285,9 @@ const Tokens = forwardRef( const cashEmptyDescription = showOnlyMusd && hasMusdBalanceOnAnyChainProp - ? strings('homepage.sections.cash_empty_description_network_filter') + ? strings('homepage.sections.money_empty_description_network_filter') : showOnlyMusd - ? strings('homepage.sections.cash_empty_description') + ? strings('homepage.sections.money_empty_description') : undefined; const emptyState = ( @@ -324,6 +331,7 @@ const Tokens = forwardRef( isGeoEligible, listFooterComponent, refreshControl, + hideSecondaryPriceRow, ]); return ( diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js index e0a35415e5b..55bbda471ab 100644 --- a/app/components/UI/TransactionElement/index.js +++ b/app/components/UI/TransactionElement/index.js @@ -26,7 +26,10 @@ import { } from '@metamask/transaction-controller'; import { ThemeContext, mockTheme } from '../../../util/theme'; import { selectTickerByChainId } from '../../../selectors/networkController'; -import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; +import { + selectSelectedInternalAccount, + selectSelectedInternalAccountAddress, +} from '../../../selectors/accountsController'; import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; import { selectPrimaryCurrency } from '../../../selectors/settings'; import { @@ -46,6 +49,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { NetworkBadgeSource } from '../AssetOverview/Balance/Balance'; import { getFontFamily, @@ -56,7 +60,7 @@ import { selectCurrencyRates, } from '../../../selectors/currencyRateController'; import { selectContractExchangeRatesByChainId } from '../../../selectors/tokenRatesController'; -import { selectTokensByChainIdAndAddress } from '../../../selectors/tokensController'; +import { selectTokensByChainIdAndWalletAddress } from '../../../selectors/tokensController'; import Routes from '../../../constants/navigation/Routes'; import { hasGasFeeTokenSelected, @@ -98,6 +102,10 @@ const createStyles = (colors, typography) => width: 32, height: 32, }, + iconBadgePosition: { + bottom: -4, + right: -4, + }, importText: { color: colors.text.alternative, fontSize: 14, @@ -224,6 +232,10 @@ class TransactionElement extends PureComponent { * Chain Id */ txChainId: PropTypes.string, + /** + * Selected wallet address for decoding and token map (optional override from parent) + */ + selectedAddress: PropTypes.string, /** * Ticker */ @@ -264,13 +276,13 @@ class TransactionElement extends PureComponent { mounted = false; componentDidMount = async () => { + this.mounted = true; const [transactionElement, transactionDetails] = await decodeTransaction({ ...this.props, swapsTransactions: this.props.swapsTransactions, assetSymbol: this.props.assetSymbol, ticker: this.props.ticker, }); - this.mounted = true; this.mounted && this.setState({ transactionElement, transactionDetails }); }; @@ -278,7 +290,8 @@ class TransactionElement extends PureComponent { componentDidUpdate(prevProps) { if ( prevProps.txChainId !== this.props.txChainId || - prevProps.swapsTransactions !== this.props.swapsTransactions + prevProps.swapsTransactions !== this.props.swapsTransactions || + prevProps.selectedAddress !== this.props.selectedAddress ) { this.componentDidMount(); } @@ -461,10 +474,13 @@ class TransactionElement extends PureComponent { return ( } > @@ -707,6 +723,35 @@ class TransactionElement extends PureComponent { ); }; + renderPendingElement = () => { + const { i, tx } = this.props; + const { colors, typography } = this.context || mockTheme; + const styles = createStyles(colors, typography); + + return ( + + + {this.renderTxTime()} + + + + + + + + ... + + + + + + ); + }; + render() { const { tx, selectedInternalAccount } = this.props; const { transactionElement, transactionDetails } = this.state; @@ -714,7 +759,7 @@ class TransactionElement extends PureComponent { const { colors, typography } = this.context || mockTheme; const styles = createStyles(colors, typography); - if (!transactionElement || !transactionDetails) return null; + const isReady = Boolean(transactionElement && transactionDetails); const accountImportTime = selectedInternalAccount?.metadata.importTime; const { time } = tx; @@ -726,11 +771,13 @@ class TransactionElement extends PureComponent { style={ this.props.showBottomBorder ? styles.rowWithBorder : styles.row } - onPress={this.onPressItem} + onPress={isReady ? this.onPressItem : undefined} underlayColor={colors.background.alternative} activeOpacity={1} > - {this.renderTxElement(transactionElement)} + {isReady + ? this.renderTxElement(transactionElement) + : this.renderPendingElement()} {accountImportTime <= time && this.renderImportTime()} @@ -738,21 +785,29 @@ class TransactionElement extends PureComponent { } } -const mapStateToProps = (state, ownProps) => ({ - selectedInternalAccount: selectSelectedInternalAccount(state), - selectSelectedAccountGroupInternalAccounts: - selectSelectedAccountGroupInternalAccounts(state), - primaryCurrency: selectPrimaryCurrency(state), - swapsTransactions: selectSwapsTransactions(state), - ticker: selectTickerByChainId(state, ownProps.txChainId), - conversionRate: selectConversionRateByChainId(state, ownProps.txChainId), - currencyRates: selectCurrencyRates(state), - contractExchangeRates: selectContractExchangeRatesByChainId( - state, - ownProps.txChainId, - ), - tokens: selectTokensByChainIdAndAddress(state, ownProps.txChainId), -}); +const mapStateToProps = (state, ownProps) => { + const walletAddressForTokens = + ownProps.selectedAddress ?? selectSelectedInternalAccountAddress(state); + return { + selectedInternalAccount: selectSelectedInternalAccount(state), + selectSelectedAccountGroupInternalAccounts: + selectSelectedAccountGroupInternalAccounts(state), + primaryCurrency: selectPrimaryCurrency(state), + swapsTransactions: selectSwapsTransactions(state), + ticker: selectTickerByChainId(state, ownProps.txChainId), + conversionRate: selectConversionRateByChainId(state, ownProps.txChainId), + currencyRates: selectCurrencyRates(state), + contractExchangeRates: selectContractExchangeRatesByChainId( + state, + ownProps.txChainId, + ), + tokens: selectTokensByChainIdAndWalletAddress( + state, + ownProps.txChainId, + walletAddressForTokens, + ), + }; +}; TransactionElement.contextType = ThemeContext; diff --git a/app/components/UI/UrlAutocomplete/Result.tsx b/app/components/UI/UrlAutocomplete/Result.tsx index 9c7483266d4..c78453bcf70 100644 --- a/app/components/UI/UrlAutocomplete/Result.tsx +++ b/app/components/UI/UrlAutocomplete/Result.tsx @@ -4,7 +4,7 @@ import { useTheme } from '../../../util/theme'; import { getHost } from '../../../util/browser'; import WebsiteIcon from '../WebsiteIcon'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; -import { deleteFavoriteTestId } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds'; +import { deleteFavoriteTestId } from './UrlAutocomplete.testIds'; import { Box, Icon, diff --git a/app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts b/app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts new file mode 100644 index 00000000000..3cb21f0a3d8 --- /dev/null +++ b/app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts @@ -0,0 +1 @@ +export const deleteFavoriteTestId = (url: string) => `delete-favorite-${url}`; diff --git a/app/components/UI/UrlAutocomplete/index.test.tsx b/app/components/UI/UrlAutocomplete/index.test.tsx index 7419393ce9c..3b43cee2db0 100644 --- a/app/components/UI/UrlAutocomplete/index.test.tsx +++ b/app/components/UI/UrlAutocomplete/index.test.tsx @@ -143,7 +143,7 @@ jest.mock('../Bridge/hooks/useSwapBridgeNavigation', () => ({ import React from 'react'; import UrlAutocomplete, { UrlAutocompleteRef } from './'; -import { deleteFavoriteTestId } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds'; +import { deleteFavoriteTestId } from './UrlAutocomplete.testIds'; import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; import renderWithProvider, { DeepPartial, diff --git a/app/components/UI/WebviewError/WebviewError.testIds.ts b/app/components/UI/WebviewError/WebviewError.testIds.ts new file mode 100644 index 00000000000..dfb8b777463 --- /dev/null +++ b/app/components/UI/WebviewError/WebviewError.testIds.ts @@ -0,0 +1,5 @@ +export const WebviewErrorSelectorsIDs = { + TITLE: 'error-page-title', + MESSAGE: 'error-page-message', + RETURN_BUTTON: 'error-page-return-button', +}; diff --git a/app/components/UI/WebviewError/index.js b/app/components/UI/WebviewError/index.js index a2d6d0004b3..bdae4ab0fd2 100644 --- a/app/components/UI/WebviewError/index.js +++ b/app/components/UI/WebviewError/index.js @@ -1,16 +1,11 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Image, StyleSheet, View, Text, Platform } from 'react-native'; +import { Image, StyleSheet, View, Text } from 'react-native'; import StyledButton from '../StyledButton'; import { strings } from '../../../../locales/i18n'; import { fontStyles } from '../../../styles/common'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { - ERROR_PAGE_MESSAGE, - ERROR_PAGE_RETURN_BUTTON, - ERROR_PAGE_TITLE, -} from '../../../../wdio/screen-objects/testIDs/BrowserScreen/ExternalWebsites.testIds'; +import { WebviewErrorSelectorsIDs } from './WebviewError.testIds'; const createStyles = (colors) => StyleSheet.create({ @@ -102,13 +97,13 @@ export default class WebviewError extends PureComponent { {strings('webview_error.title')} {strings('webview_error.message')} @@ -118,7 +113,7 @@ export default class WebviewError extends PureComponent { {strings('webview_error.return_home')} diff --git a/app/components/Views/BrowserTab/components/Options/Options.testIds.ts b/app/components/Views/BrowserTab/components/Options/Options.testIds.ts new file mode 100644 index 00000000000..dac9ec7070a --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/Options.testIds.ts @@ -0,0 +1,10 @@ +export const BrowserOptionsSelectorsIDs = { + MENU: 'browser-options-menu', + ADD_FAVORITES: 'browser-options-menu-add-favorites', + OPEN_FAVORITES: 'browser-options-menu-open-favorites', + NEW_TAB: 'browser-options-menu-new-tab', + RELOAD: 'browser-options-menu-reload', + SHARE: 'browser-options-menu-share', + OPEN_IN_BROWSER: 'browser-options-menu-open-in-browser', + SWITCH_NETWORK: 'browser-options-switch-browser', +}; diff --git a/app/components/Views/BrowserTab/components/Options/index.tsx b/app/components/Views/BrowserTab/components/Options/index.tsx index 607a2cc9185..664216f2ee2 100644 --- a/app/components/Views/BrowserTab/components/Options/index.tsx +++ b/app/components/Views/BrowserTab/components/Options/index.tsx @@ -1,27 +1,18 @@ import React, { MutableRefObject, useCallback } from 'react'; import { Linking, - Platform, Text, TouchableWithoutFeedback, View, ImageSourcePropType, } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import generateTestId from '../../../../../../wdio/utils/generateTestId'; import Device from '../../../../../util/device'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './styles'; import Button from '../../../../UI/Button'; import { strings } from '../../../../../../locales/i18n'; -import { - ADD_FAVORITES_OPTION, - MENU_ID, - NEW_TAB_OPTION, - OPEN_IN_BROWSER_OPTION, - RELOAD_OPTION, - SHARE_OPTION, -} from '../../../../../../wdio/screen-objects/testIDs/BrowserScreen/OptionMenu.testIds'; +import { BrowserOptionsSelectorsIDs } from './Options.testIds'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -194,7 +185,7 @@ const Options = ({ {strings('browser.share')} @@ -216,7 +207,7 @@ const Options = ({ {strings('browser.reload')} @@ -235,7 +226,7 @@ const Options = ({ ? styles.optionsWrapperAndroid : styles.optionsWrapperIos, ]} - {...generateTestId(Platform, MENU_ID)} + testID={BrowserOptionsSelectorsIDs.MENU} > ); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx index 7bf75357504..6a25fd84b10 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx @@ -1,11 +1,8 @@ -import React, { useCallback } from 'react'; -import { useNavigation, NavigationProp } from '@react-navigation/native'; -import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import React from 'react'; import type { RelatedAsset } from '@metamask/ai-controllers'; -import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; -import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import AssetRow from './AssetRow'; +import useTradeNavigation from '../hooks/useTradeNavigation'; interface PerpsRowProps { asset: RelatedAsset; @@ -18,19 +15,7 @@ interface PerpsRowProps { * be called per-asset (hooks cannot be called inside a loop). */ const PerpsRow: React.FC = ({ asset }) => { - const navigation = useNavigation>(); - const hlPerpsMarket = asset.hlPerpsMarket?.[0]; - - const handleTrade = useCallback(() => { - if (!hlPerpsMarket) return; - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { - market: { symbol: hlPerpsMarket, name: asset.name }, - source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, - }, - }); - }, [navigation, hlPerpsMarket, asset.name]); + const { handleTrade } = useTradeNavigation(asset); return ( ({ useRampNavigation: () => ({ goToBuy: mockGoToBuy }), })); +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + jest.mock('../utils/getRelatedAssetImageSource', () => ({ getRelatedAssetImageSource: jest.fn(() => undefined), })); @@ -21,12 +31,12 @@ const btcAsset: RelatedAsset = { caip19: ['eip155:1/slip44:0'], }; -const perpsOnlyAsset: RelatedAsset = { - sourceAssetId: 'tsla', - symbol: 'TSLA', - name: 'Tesla', - caip19: [], - hlPerpsMarket: ['xyz:TSLA'], +const dualAsset: RelatedAsset = { + sourceAssetId: 'eth', + symbol: 'ETH', + name: 'Ethereum', + caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], }; describe('TokenRow', () => { @@ -34,27 +44,48 @@ describe('TokenRow', () => { jest.clearAllMocks(); }); - it('renders the asset symbol', () => { - renderWithProvider(); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - }); + describe('when asset has only caip19 (no hlPerpsMarket)', () => { + it('renders the asset symbol', () => { + renderWithProvider(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); - it('renders the Buy button', () => { - renderWithProvider(); - expect(screen.getByText('Buy')).toBeOnTheScreen(); - }); + it('renders the Buy button', () => { + renderWithProvider(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + }); - it('calls goToBuy with the first caip19 identifier on Buy press', () => { - renderWithProvider(); - fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: 'eip155:1/slip44:0', + it('calls goToBuy with the first caip19 identifier on Buy press', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: 'eip155:1/slip44:0', + }); }); }); - it('calls goToBuy with assetId undefined when caip19 is empty', () => { - renderWithProvider(); - fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined }); + describe('when asset has hlPerpsMarket (dual asset)', () => { + it('renders the Trade button instead of Buy', () => { + renderWithProvider(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.queryByText('Buy')).toBeNull(); + }); + + it('navigates to Perps market details on Trade press', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'ETH', name: 'Ethereum' }, + }), + }); + }); + + it('does not call goToBuy when Trade is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx index de0076fd2ab..26394755c5b 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx @@ -3,6 +3,7 @@ import type { RelatedAsset } from '@metamask/ai-controllers'; import { strings } from '../../../../../locales/i18n'; import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation'; import AssetRow from './AssetRow'; +import useTradeNavigation from '../hooks/useTradeNavigation'; interface TokenRowProps { asset: RelatedAsset; @@ -10,18 +11,31 @@ interface TokenRowProps { /** * A single row in the Tokens section of the expanded What's Happening card. - * Displays the token logo, symbol, and a Buy button that navigates to the + * Shows a Trade button (navigating to Perps) when the asset has an + * `hlPerpsMarket` entry; otherwise falls back to a Buy button that opens the * Ramp buy flow. Extracted as its own component so hooks can be called * per-asset (hooks cannot be called inside a loop). */ const TokenRow: React.FC = ({ asset }) => { const { goToBuy } = useRampNavigation(); + const { handleTrade, canTrade } = useTradeNavigation(asset); const handleBuy = useCallback(() => { const assetId = asset.caip19?.[0]; goToBuy({ assetId }); }, [goToBuy, asset.caip19]); + if (canTrade) { + return ( + + ); + } + return ( { it('renders the title and description', () => { renderWithProvider( - , + , ); expect(screen.getByText(baseItem.title)).toBeOnTheScreen(); expect(screen.getByText(baseItem.description)).toBeOnTheScreen(); @@ -86,7 +91,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders the impact badge for positive impact', () => { renderWithProvider( - , + , ); expect(screen.getByText('Bullish')).toBeOnTheScreen(); }); @@ -94,7 +103,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders Neutral badge when impact is explicitly neutral', () => { const item = { ...baseItem, impact: 'neutral' as const }; renderWithProvider( - , + , ); expect(screen.getByText('Neutral')).toBeOnTheScreen(); }); @@ -102,7 +115,11 @@ describe('WhatsHappeningExpandedCard', () => { it('does not render an impact badge when impact is undefined', () => { const item = { ...baseItem, impact: undefined }; renderWithProvider( - , + , ); expect(screen.queryByText('Neutral')).toBeNull(); expect(screen.queryByText('Bullish')).toBeNull(); @@ -112,7 +129,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders Tokens section when assets have caip19', () => { const item = { ...baseItem, relatedAssets: [tokenAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Tokens')).toBeOnTheScreen(); expect(screen.getByText('BTC')).toBeOnTheScreen(); @@ -122,7 +143,11 @@ describe('WhatsHappeningExpandedCard', () => { it('does not render Tokens section when no assets have caip19', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( - , + , ); expect(screen.queryByText('Tokens')).toBeNull(); expect(screen.queryByText('Buy')).toBeNull(); @@ -131,7 +156,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders Perps section when assets have hlPerpsMarket', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Perps')).toBeOnTheScreen(); expect(screen.getByText('TSLA')).toBeOnTheScreen(); @@ -141,7 +170,11 @@ describe('WhatsHappeningExpandedCard', () => { it('does not render Perps section when no assets have hlPerpsMarket', () => { const item = { ...baseItem, relatedAssets: [tokenAsset] }; renderWithProvider( - , + , ); expect(screen.queryByText('Perps')).toBeNull(); expect(screen.queryByText('Trade')).toBeNull(); @@ -150,7 +183,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders both Tokens and Perps sections when there are separate token and perps-only assets', () => { const item = { ...baseItem, relatedAssets: [tokenAsset, perpsOnlyAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Tokens')).toBeOnTheScreen(); expect(screen.getByText('Perps')).toBeOnTheScreen(); @@ -158,20 +195,28 @@ describe('WhatsHappeningExpandedCard', () => { expect(screen.getByText('Trade')).toBeOnTheScreen(); }); - it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section', () => { + it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section, shows Trade for the token row', () => { const item = { ...baseItem, relatedAssets: [dualAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Tokens')).toBeOnTheScreen(); - expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.queryByText('Buy')).toBeNull(); expect(screen.queryByText('Perps')).toBeNull(); - expect(screen.queryByText('Trade')).toBeNull(); }); it('renders neither section when relatedAssets is empty', () => { renderWithProvider( - , + , ); expect(screen.queryByText('Tokens')).toBeNull(); expect(screen.queryByText('Perps')).toBeNull(); @@ -180,7 +225,11 @@ describe('WhatsHappeningExpandedCard', () => { it('Trade button navigates to PerpsMarketDetails', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( - , + , ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx index 044a65e72eb..8359a15efcf 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx @@ -31,11 +31,14 @@ import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet interface WhatsHappeningExpandedCardProps { item: WhatsHappeningItem; cardWidth: number; + /** Height of the carousel container — used to give every card the same fixed height. */ + cardHeight: number; } const WhatsHappeningExpandedCard: React.FC = ({ item, cardWidth, + cardHeight, }) => { const tw = useTailwind(); const [sourcesVisible, setSourcesVisible] = useState(false); @@ -61,17 +64,15 @@ const WhatsHappeningExpandedCard: React.FC = ({ }, [uniqueSources]); return ( - - - + {/* Card surface — fills the fixed height so all cards are the same size */} + + {/* Scrollable main content */} + - {/* Impact badge — only rendered when impact is explicitly set */} + {/* Impact badge */} {item.impact && ( @@ -99,7 +100,7 @@ const WhatsHappeningExpandedCard: React.FC = ({ )} - {/* Tokens section — only assets with a purchasable CAIP-19 identifier */} + {/* Tokens section */} {item.relatedAssets.some((asset) => asset.caip19?.length) && ( = ({ ))} )} + - {/* Sources trigger */} - {uniqueSources.length > 0 && ( - <> - + {/* Fixed sources footer — always pinned to the bottom of the card */} + {uniqueSources.length > 0 && ( + + - setSourcesVisible(true)} - accessibilityRole="button" - > - {({ pressed }) => ( + setSourcesVisible(true)} + accessibilityRole="button" + > + {({ pressed }) => ( + - - - {sourceLabel ? ( - - {sourceLabel} - - ) : null} - - - {item.date ? ( + + {sourceLabel ? ( - {formatRelativeTime(item.date, { nowLabel: 'now' })} + {sourceLabel} ) : null} - )} - - - )} - - + + {item.date ? ( + + {formatRelativeTime(item.date, { nowLabel: 'now' })} + + ) : null} + + )} + + + )} + {sourcesVisible && ( void; + /** True when the asset has at least one `hlPerpsMarket` entry. */ + canTrade: boolean; +} + +/** + * Provides a stable `handleTrade` callback and a `canTrade` flag for an asset. + * `handleTrade` is always a valid function — it is a no-op when the asset has + * no `hlPerpsMarket` entry. Use `canTrade` to decide whether to show a Trade + * button at all. + */ +const useTradeNavigation = (asset: RelatedAsset): UseTradeNavigationResult => { + const navigation = useNavigation>(); + const hlPerpsMarket = asset.hlPerpsMarket?.[0]; + + const handleTrade = useCallback(() => { + if (!hlPerpsMarket) return; + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: hlPerpsMarket, name: asset.name }, + source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, + }, + }); + }, [navigation, hlPerpsMarket, asset.name]); + + return { handleTrade, canTrade: Boolean(hlPerpsMarket) }; +}; + +export default useTradeNavigation; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 0593e003de7..c2ec31e80f8 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -435,6 +435,7 @@ const Routes = { TRANSFER_MONEY_SHEET: 'MoneyTransferSheet', APY_INFO_SHEET: 'MoneyApyInfoSheet', EARNINGS_INFO_SHEET: 'MoneyEarningsInfoSheet', + MONEY_BALANCE_INFO_SHEET: 'MoneyBalanceInfoSheet', }, }, FULL_SCREEN_CONFIRMATIONS: { diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index b2d02a781cb..0248727b4d2 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -699,7 +699,6 @@ enum EVENT_NAME { MONEY_HUB_SCREEN_VIEWED = 'Money Hub Screen Viewed', MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED = 'Money Hub Token Row Convert Clicked', MONEY_HUB_CONVERT_BUTTON_CLICKED = 'Money Hub Convert Button Clicked', - MONEY_HUB_LEARN_MORE_PRESSED = 'Money Hub Learn More Pressed', MONEY_HUB_SWAP_BUTTON_CLICKED = 'Money Hub Swap Button Clicked', MONEY_HUB_BUY_BUTTON_CLICKED = 'Money Hub Buy Button Clicked', @@ -1828,9 +1827,6 @@ const events = { MONEY_HUB_CONVERT_BUTTON_CLICKED: generateOpt( EVENT_NAME.MONEY_HUB_CONVERT_BUTTON_CLICKED, ), - MONEY_HUB_LEARN_MORE_PRESSED: generateOpt( - EVENT_NAME.MONEY_HUB_LEARN_MORE_PRESSED, - ), MONEY_HUB_SWAP_BUTTON_CLICKED: generateOpt( EVENT_NAME.MONEY_HUB_SWAP_BUTTON_CLICKED, ), diff --git a/app/core/apiClient.test.ts b/app/core/apiClient.test.ts new file mode 100644 index 00000000000..423c37a1ff8 --- /dev/null +++ b/app/core/apiClient.test.ts @@ -0,0 +1,56 @@ +import { createApiPlatformClient } from '@metamask/core-backend'; +import Engine from './Engine'; +import './apiClient'; + +jest.mock('@metamask/core-backend', () => ({ + createApiPlatformClient: jest.fn(() => ({ accounts: {} })), +})); + +jest.mock('./Engine', () => ({ + __esModule: true, + default: { + context: { + AuthenticationController: { + getBearerToken: jest.fn(), + }, + }, + }, +})); + +const createApiPlatformClientMock = jest.mocked(createApiPlatformClient); +const getBearerTokenMock = jest.mocked( + Engine.context.AuthenticationController.getBearerToken, +); + +const [firstCallArgs] = createApiPlatformClientMock.mock.calls; +const getBearerToken = firstCallArgs[0].getBearerToken as () => Promise< + string | undefined +>; + +describe('apiClient', () => { + beforeEach(() => { + getBearerTokenMock.mockReset(); + }); + + it('creates the API platform client with the mobile product identifier', () => { + expect(createApiPlatformClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + clientProduct: 'metamask-mobile', + getBearerToken: expect.any(Function), + }), + ); + }); + + it('returns the bearer token from AuthenticationController', async () => { + getBearerTokenMock.mockResolvedValueOnce('bearer-token-mock'); + + await expect(getBearerToken()).resolves.toBe('bearer-token-mock'); + expect(getBearerTokenMock).toHaveBeenCalled(); + }); + + it('returns undefined when AuthenticationController throws', async () => { + getBearerTokenMock.mockRejectedValueOnce(new Error('boom')); + + await expect(getBearerToken()).resolves.toBeUndefined(); + }); +}); diff --git a/app/core/apiClient.ts b/app/core/apiClient.ts new file mode 100644 index 00000000000..e09744bd826 --- /dev/null +++ b/app/core/apiClient.ts @@ -0,0 +1,13 @@ +import { createApiPlatformClient } from '@metamask/core-backend'; +import Engine from './Engine'; + +export const apiClient = createApiPlatformClient({ + clientProduct: 'metamask-mobile', + getBearerToken: async () => { + try { + return await Engine.context.AuthenticationController.getBearerToken(); + } catch { + return undefined; + } + }, +}); diff --git a/app/selectors/tokensController.test.ts b/app/selectors/tokensController.test.ts index 0eee0680df8..1c453f6bb86 100644 --- a/app/selectors/tokensController.test.ts +++ b/app/selectors/tokensController.test.ts @@ -10,6 +10,7 @@ import { selectAllDetectedTokensForSelectedAddress, selectAllDetectedTokensFlat, selectTokensByChainIdAndAddress, + selectTokensByChainIdAndWalletAddress, getChainIdsToPoll, selectSingleTokenByAddressAndChainId, } from './tokensController'; @@ -337,6 +338,34 @@ describe('TokensController Selectors', () => { }); }); + describe('selectTokensByChainIdAndWalletAddress', () => { + it('returns tokens for the given chain and explicit wallet address', () => { + expect( + selectTokensByChainIdAndWalletAddress( + mockRootState, + '0x1', + '0xAddress2', + ), + ).toStrictEqual({ '0xToken2': mockToken2 }); + }); + + it('returns empty object when wallet address has no tokens on that chain', () => { + expect( + selectTokensByChainIdAndWalletAddress( + mockRootState, + '0x2', + '0xAddress1', + ), + ).toStrictEqual({}); + }); + + it('returns empty object when wallet address is undefined', () => { + expect( + selectTokensByChainIdAndWalletAddress(mockRootState, '0x1', undefined), + ).toStrictEqual({}); + }); + }); + describe('getChainIdsToPoll', () => { const mockNetworkConfigurations = { '0x1': { chainId: '0x1' } as unknown as NetworkConfiguration, diff --git a/app/selectors/tokensController.ts b/app/selectors/tokensController.ts index a1f1148fcb4..25d2b2d88fa 100644 --- a/app/selectors/tokensController.ts +++ b/app/selectors/tokensController.ts @@ -53,6 +53,34 @@ export const selectTokensByChainIdAndAddress = createDeepEqualSelector( ) ?? {}, ); +/** + * Like {@link selectTokensByChainIdAndAddress} but uses an explicit account + * address (e.g. the EVM address for the account group) instead of the globally + * selected account. Needed when the UI shows EVM activity while a non-EVM + * account is still selected. + */ +export const selectTokensByChainIdAndWalletAddress = createDeepEqualSelector( + getTokensControllerAllTokens, + (_state: RootState, chainId: Hex, _walletAddress: Hex | string | undefined) => + chainId, + (_state: RootState, _chainId: Hex, walletAddress: Hex | string | undefined) => + walletAddress, + ( + allTokens: TokensControllerState['allTokens'], + chainId: Hex, + walletAddress: Hex | string | undefined, + ) => + !walletAddress + ? {} + : (allTokens[chainId]?.[walletAddress as Hex]?.reduce( + (tokensMap: { [address: string]: Token }, token: Token) => ({ + ...tokensMap, + [token.address]: token, + }), + {}, + ) ?? {}), +); + export const selectTokensByAddress = createSelector( selectTokens, (tokens: Token[]) => diff --git a/app/selectors/transactionController.test.ts b/app/selectors/transactionController.test.ts index 2725e20d6d2..4684567699c 100644 --- a/app/selectors/transactionController.test.ts +++ b/app/selectors/transactionController.test.ts @@ -4,9 +4,16 @@ import { TransactionType } from '@metamask/transaction-controller'; import { selectTransactions, selectLastWithdrawTokenByType, + selectLocalTransactions, selectNonReplacedTransactions, + selectRequiredTransactionIds, + selectRequiredTransactionHashes, + selectRequiredTransactions, selectSwapsTransactions, + selectTransactionBatchMetadataById, selectTransactionMetadataById, + selectTransactionsByBatchId, + selectTransactionsByIds, selectSortedTransactions, selectSortedEVMTransactionsForSelectedAccountGroup, } from './transactionController'; @@ -96,6 +103,138 @@ describe('TransactionController Selectors', () => { }); }); + describe('selectRequiredTransactionHashes', () => { + it('returns hashes for required child transactions', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'parent', + requiredTransactionIds: ['child'], + }, + { + id: 'child', + hash: '0xABC', + }, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectRequiredTransactionHashes(state)).toStrictEqual( + new Set(['0xabc']), + ); + }); + }); + + describe('selectRequiredTransactionIds', () => { + it('returns required child transaction ids', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'parent', + requiredTransactionIds: ['child-1', 'child-2'], + }, + { + id: 'child-1', + }, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectRequiredTransactionIds(state)).toStrictEqual( + new Set(['child-1', 'child-2']), + ); + }); + }); + + describe('selectRequiredTransactions', () => { + it('returns transactions referenced by required ids', () => { + const child = { + id: 'child', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'parent', + requiredTransactionIds: ['child'], + }, + child, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectRequiredTransactions(state)).toStrictEqual([child]); + }); + }); + + describe('selectLocalTransactions', () => { + it('filters required child transactions before nonce dedupe', () => { + const activeEvmAddress = '0x0000000000000000000000000000000000000001'; + const state = { + engine: { + backgroundState: { + AccountsController: { + internalAccounts: { + selectedAccount: 'account-1', + accounts: { + 'account-1': { + id: 'account-1', + address: activeEvmAddress, + type: 'eip155:eoa', + }, + }, + }, + }, + TransactionController: { + transactions: [ + { + id: 'child', + hash: '0xCHILD', + chainId: '0x1', + time: 200, + txParams: { + from: activeEvmAddress, + nonce: '0x1', + }, + }, + { + id: 'parent', + chainId: '0x1', + requiredTransactionIds: ['child'], + time: 100, + type: TransactionType.predictDeposit, + txParams: { + from: activeEvmAddress, + nonce: '0x1', + }, + }, + ], + }, + }, + }, + pendingSmartTransactionsForGroup: [], + } as unknown as RootState; + + expect(selectLocalTransactions(state)).toStrictEqual([ + expect.objectContaining({ id: 'parent' }), + ]); + }); + }); + describe('selectTransactionMetadataById', () => { it('returns the transaction matching the given id', () => { const transactions = [ @@ -138,6 +277,78 @@ describe('TransactionController Selectors', () => { }); }); + describe('selectTransactionBatchMetadataById', () => { + it('returns the transaction batch matching the given id', () => { + const batch = { + id: 'batch-id', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [], + transactionBatches: [batch], + }, + }, + }, + } as unknown as RootState; + + expect(selectTransactionBatchMetadataById(state, 'batch-id')).toBe(batch); + }); + }); + + describe('selectTransactionsByIds', () => { + it('returns matching transactions in requested id order', () => { + const first = { + id: 'first', + }; + const second = { + id: 'second', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [first, second], + }, + }, + }, + } as unknown as RootState; + + expect( + selectTransactionsByIds(state, ['second', 'missing', 'first']), + ).toStrictEqual([second, first]); + }); + }); + + describe('selectTransactionsByBatchId', () => { + it('returns transactions matching the batch id', () => { + const matchingTransaction = { + id: 'matching', + batchId: 'batch-id', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + matchingTransaction, + { + id: 'other', + batchId: 'other-batch-id', + }, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectTransactionsByBatchId(state, 'batch-id')).toStrictEqual([ + matchingTransaction, + ]); + }); + }); + describe('selectSortedTransactions', () => { it('merges non-replaced transactions and pending smart transactions and sorts them descending by time', () => { // Transactions with one replaced transaction and two non-replaced ones diff --git a/app/selectors/transactionController.ts b/app/selectors/transactionController.ts index 0c34e5e5c65..fc0ba1b6abd 100644 --- a/app/selectors/transactionController.ts +++ b/app/selectors/transactionController.ts @@ -5,17 +5,55 @@ import { selectPendingSmartTransactionsBySender, selectPendingSmartTransactionsForSelectedAccountGroup, } from './smartTransactionsController'; +import { selectEvmAddress } from './accountsController'; import { TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { SmartTransaction } from '@metamask/smart-transactions-controller'; +import { areAddressesEqual } from '../util/address'; interface MetaMaskPayToken { address: Hex; chainId: Hex; } +type LocalTransaction = TransactionMeta | SmartTransaction; + +// Extracted from UnifiedTransactionsView +function dedupeTransactions(transactions: LocalTransaction[]) { + const seenTransactions = new Set(); + + return transactions.filter((transaction) => { + const { chainId, txParams } = transaction; + const { from, nonce, actionId } = txParams || {}; + const hash = 'hash' in transaction ? transaction.hash : undefined; + const isBridgeTransaction = transaction.type === TransactionType.bridge; + const hasNonce = nonce !== undefined && nonce !== null; + + if (!from) { + return false; + } + + const dedupeKeyPrefix = `${chainId}-${String(from).toLowerCase()}`; + const dedupeKey = + isBridgeTransaction && hash + ? `${dedupeKeyPrefix}-bridge-${hash.toLowerCase()}` + : hasNonce + ? `${dedupeKeyPrefix}-${nonce}` + : `${dedupeKeyPrefix}-${actionId}`; + + // Keep only the first local transaction for each dedupe key + if (seenTransactions.has(dedupeKey)) { + return false; + } + + seenTransactions.add(dedupeKey); + return true; + }); +} + function getNestedTransactionTypes( transaction: TransactionMeta, ): TransactionType[] { @@ -55,6 +93,28 @@ const selectTransactionBatchesStrict = createSelector( (transactionControllerState) => transactionControllerState.transactionBatches, ); +export const selectRequiredTransactionIds = createSelector( + selectTransactionsStrict, + (transactions) => + new Set(transactions.flatMap((tx) => tx.requiredTransactionIds ?? [])), +); + +export const selectRequiredTransactions = createSelector( + [selectTransactionsStrict, selectRequiredTransactionIds], + (transactions, requiredTransactionIds) => + transactions.filter((tx) => requiredTransactionIds.has(tx.id)), +); + +export const selectRequiredTransactionHashes = createSelector( + selectRequiredTransactions, + (transactions) => + new Set( + transactions + .map((tx) => tx.hash?.toLowerCase()) + .filter((hash): hash is string => Boolean(hash)), + ), +); + export const selectTransactions = createDeepEqualSelector( selectTransactionsStrict, (transactions) => transactions, @@ -125,6 +185,49 @@ export const selectSortedEVMTransactionsForSelectedAccountGroup = ), ); +export const selectLocalTransactions = createDeepEqualSelector( + [ + selectNonReplacedTransactions, + selectPendingSmartTransactionsForSelectedAccountGroup, + selectEvmAddress, + selectRequiredTransactionIds, + ], + ( + nonReplacedTransactions, + pendingSmartTransactions, + activeEvmAddress, + requiredTransactionIds, + ) => { + const transactions = nonReplacedTransactions.filter((transaction) => { + if (requiredTransactionIds.has(transaction.id)) { + return false; + } + + const fromAddress = transaction.txParams?.from; + if (!fromAddress || !activeEvmAddress) { + return false; + } + + return areAddressesEqual(fromAddress, activeEvmAddress); + }); + + const pendingSmartTransactionsForActiveAddress = + pendingSmartTransactions.filter((transaction) => { + const fromAddress = transaction.txParams?.from; + if (!fromAddress || !activeEvmAddress) { + return false; + } + + return areAddressesEqual(fromAddress, activeEvmAddress); + }); + + return dedupeTransactions([ + ...transactions, + ...pendingSmartTransactionsForActiveAddress, + ]).sort((a, b) => (b?.time ?? 0) - (a?.time ?? 0)); + }, +); + export const selectSwapsTransactions = createSelector( selectTransactionControllerState, (transactionControllerState) => diff --git a/app/util/bridge/hooks/useBridgeTxHistoryData.ts b/app/util/bridge/hooks/useBridgeTxHistoryData.ts index ca514c7de25..ac0fec55e98 100644 --- a/app/util/bridge/hooks/useBridgeTxHistoryData.ts +++ b/app/util/bridge/hooks/useBridgeTxHistoryData.ts @@ -6,6 +6,7 @@ import { import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController'; import { Transaction } from '@metamask/keyring-api'; import { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import { equalsIgnoreCase } from '../../string'; export const FINAL_NON_CONFIRMED_STATUSES = [ TransactionStatus.failed, @@ -46,16 +47,23 @@ export function useBridgeTxHistoryData({ // If not found, try to find by originalTransactionId for intent transactions if (!bridgeHistoryItem && srcTxMetaId) { const matchingEntry = Object.entries(bridgeHistory).find( - ([_, historyItem]) => - (historyItem as unknown as { originalTransactionId: string }) + ([, historyItem]) => + (historyItem as { originalTransactionId?: string }) .originalTransactionId === srcTxMetaId, ); bridgeHistoryItem = matchingEntry ? matchingEntry[1] : undefined; } + + // Fallback for API-normalized transactions whose id differs from txMetaId + if (!bridgeHistoryItem && evmTxMeta.hash) { + bridgeHistoryItem = Object.values(bridgeHistory).find((item) => + equalsIgnoreCase(item.status.srcChain.txHash, evmTxMeta.hash), + ); + } } else if (multiChainTx) { const srcTxHash = multiChainTx?.id; - bridgeHistoryItem = Object.values(bridgeHistory).find( - (item) => item.status.srcChain.txHash === srcTxHash, + bridgeHistoryItem = Object.values(bridgeHistory).find((item) => + equalsIgnoreCase(item.status.srcChain.txHash, srcTxHash), ); } diff --git a/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts b/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts index 18b18f29020..628d0fc4d15 100644 --- a/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts +++ b/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts @@ -105,6 +105,35 @@ describe('useBridgeTxHistoryData', () => { }); }); + it('should find bridge history item by EVM transaction hash', async () => { + const tx: TransactionMeta = { + id: 'api-normalized-transaction-id', + hash: mockTxHash, + status: TransactionStatus.confirmed, + chainId: mockChainId, + networkClientId: 'mainnet', + time: Date.now(), + txParams: { + to: '0x123', + from: '0x456', + value: '0x0', + data: '0x', + }, + }; + + const { result } = renderHookWithProvider( + () => useBridgeTxHistoryData({ evmTxMeta: tx }), + { + state: initialState, + }, + ); + + await waitFor(() => { + expect(result.current.bridgeTxHistoryItem?.txMetaId).toBe(mockTxId); + expect(result.current.isBridgeComplete).toBe(true); + }); + }); + it('should find bridge history item by multi-chain transaction hash', async () => { const multiChainTx: Transaction = { id: mockTxHash, diff --git a/app/util/string/index.test.ts b/app/util/string/index.test.ts index 99f46d7b33c..42ca951e3d3 100644 --- a/app/util/string/index.test.ts +++ b/app/util/string/index.test.ts @@ -1,4 +1,5 @@ import { + equalsIgnoreCase, escapeSpecialUnicode, isArrayType, isSolidityType, @@ -23,6 +24,29 @@ describe('string utils', () => { }); }); + describe('equalsIgnoreCase', () => { + it('returns true for identical strings', () => { + expect(equalsIgnoreCase('hello', 'hello')).toBe(true); + }); + + it('returns true for strings differing only in case', () => { + expect(equalsIgnoreCase('Hello', 'hELLo')).toBe(true); + expect(equalsIgnoreCase('0xABC123', '0xabc123')).toBe(true); + }); + + it('returns false for different strings', () => { + expect(equalsIgnoreCase('hello', 'world')).toBe(false); + }); + + it('returns false when either value is nullish or empty', () => { + expect(equalsIgnoreCase(undefined, 'hello')).toBe(false); + expect(equalsIgnoreCase('hello', undefined)).toBe(false); + expect(equalsIgnoreCase(null, null)).toBe(false); + expect(equalsIgnoreCase('', 'hello')).toBe(false); + expect(equalsIgnoreCase('', '')).toBe(false); + }); + }); + describe('isArrayType', () => { [ ['uint256[]', true], diff --git a/app/util/string/index.ts b/app/util/string/index.ts index f797c181676..360411ce0e9 100644 --- a/app/util/string/index.ts +++ b/app/util/string/index.ts @@ -27,6 +27,16 @@ export const stripMultipleNewlines = ( return str.replace(/\n+/g, '\n'); }; +export const equalsIgnoreCase = ( + a: string | undefined | null, + b: string | undefined | null, +) => { + if (!a || !b) { + return false; + } + return a.toLowerCase() === b.toLowerCase(); +}; + const solidityTypes = () => { const types = [ 'bool', diff --git a/locales/languages/de.json b/locales/languages/de.json index 28b98585ba4..de51c7d3d4e 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8512,7 +8512,6 @@ "empty": "Sie haben keine Positionen", "empty_description": "Sammeln Sie Belohnungen, indem Sie eine Position in tokenisierten realen Assets eröffnen.", "empty_cta": "Eine Position öffnen", - "position_units": "{{units}} Aktien", "positions_title": "Positionen", "activity_title": "Aktivität", "view_activity": "Aktivität anzeigen", diff --git a/locales/languages/el.json b/locales/languages/el.json index 13ec23005cd..18b539b9e58 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8512,7 +8512,6 @@ "empty": "Δεν έχετε ανοιχτές θέσεις", "empty_description": "Κερδίστε ανταμοιβές ανοίγοντας μια θέση σε πραγματικά ψηφιακά περιουσιακά στοιχεία ως token.", "empty_cta": "Ανοίξτε μια θέση", - "position_units": "{{units}} μετοχές", "positions_title": "Θέσεις", "activity_title": "Δραστηριότητα", "view_activity": "Προβολή δραστηριότητας", diff --git a/locales/languages/en.json b/locales/languages/en.json index 5205ab4d836..ed11d39bc8e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6476,10 +6476,17 @@ }, "education": { "heading": "GET {{percentage}}% ON\nSTABLECOINS", - "description": "Convert your stablecoins to mUSD and earn up to a {{percentage}}% annualized bonus that you can claim daily.", + "description": "Convert your stablecoins to mUSD and earn a {{percentage}}% annualized bonus.", "terms_apply": "Terms apply.", "primary_button": "Get Started", - "secondary_button": "Not now" + "secondary_button": "Not now", + "checklist": { + "dollar_backed": "Dollar-backed", + "no_lockups": "No lockups", + "daily_bonus": "Daily bonus", + "metamask_stablecoins": "MetaMask stablecoins", + "no_metamask_fee": "No MetaMask fee" + } }, "buy_musd": "Buy mUSD", "get_musd": "Get mUSD", @@ -6523,6 +6530,7 @@ }, "money": { "title": "Money", + "your_balance": "Your balance", "apy_label": "{{percentage}}% APY", "apy_info_label": "APY info", "onboarding": { @@ -6556,6 +6564,12 @@ "musd_row": { "add": "Add" }, + "balance_card": { + "label": "Money balance", + "add": "Add", + "info_sheet_title": "Money Home", + "info_sheet_body": "Your dollar-backed balance, always available. Spend it, send it, or trade it anytime. We don't calculate this into your total account balance." + }, "potential_earnings": { "title": "Earn on your crypto", "description": "See how your money can grow over time by converting your crypto to mUSD.", @@ -8521,7 +8535,6 @@ "empty": "You don't have any positions", "empty_description": "Start earning rewards by opening a position in tokenized real-world assets.", "empty_cta": "Open a position", - "position_units": "{{units}} shares", "positions_title": "Positions", "activity_title": "Activity", "view_activity": "View activity", @@ -9000,14 +9013,14 @@ }, "homepage": { "sections": { - "cash": "Money", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", - "cash_empty_state": { + "money": "Money", + "money_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.", + "money_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "money_empty_state": { "get_started": "Get started", "earn_apy": "Earn {{percentage}}% APY" }, - "cash_filled_state": { + "money_filled_state": { "add": "Add", "apy": "{{percentage}}% APY" }, diff --git a/locales/languages/es.json b/locales/languages/es.json index 7a1d9ed8af8..c35c1bfac80 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8512,7 +8512,6 @@ "empty": "No tienes ninguna posición", "empty_description": "Comienza a ganar recompensas abriendo una posición en activos del mundo real tokenizados.", "empty_cta": "Abrir una posición", - "position_units": "{{units}} acciones", "positions_title": "Posiciones", "activity_title": "Actividad", "view_activity": "Ver actividad", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 22621832f2d..fee85edbbb6 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8512,7 +8512,6 @@ "empty": "Vous n’avez aucune position", "empty_description": "Commencez à gagner des récompenses en ouvrant une position sur des actifs du monde réel tokenisés.", "empty_cta": "Ouvrir une position", - "position_units": "{{units}} actions", "positions_title": "Positions", "activity_title": "Activité", "view_activity": "Voir l’activité", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index ced9d00e771..e1b0f6b416d 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8512,7 +8512,6 @@ "empty": "आपके पास कोई पोज़िशन नहीं है", "empty_description": "टोकन वाले रियल-वर्ल्ड एसेट्स में पोज़िशन खोलकर रिवॉर्ड कमाना शुरू करें।", "empty_cta": "एक पोज़िशन खोलें", - "position_units": "{{units}} शेयर", "positions_title": "पोजीशन्स", "activity_title": "गतिविधि", "view_activity": "एक्टिविटी देखें", diff --git a/locales/languages/id.json b/locales/languages/id.json index b9384ae959a..504c9d778b2 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8512,7 +8512,6 @@ "empty": "Anda tidak memiliki posisi", "empty_description": "Mulailah mendapatkan reward dengan membuka posisi pada aset dunia nyata yang di tokenisasi.", "empty_cta": "Buka posisi", - "position_units": "{{units}} saham", "positions_title": "Posisi", "activity_title": "Aktivitas", "view_activity": "Lihat aktivitas", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 62c3a10836a..216cbf2b7d3 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8512,7 +8512,6 @@ "empty": "ポジションがありません", "empty_description": "RWAトークンのポジションをオープンして、報酬の獲得を始めましょう。", "empty_cta": "ポジションをオープン", - "position_units": "{{units}}株", "positions_title": "ポジション", "activity_title": "アクティビティ", "view_activity": "アクティビティを表示", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 01372f65546..8ffd7cc0102 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8512,7 +8512,6 @@ "empty": "포지션이 없습니다", "empty_description": "토큰화된 실물 자산에서 포지션을 개설해 보상 적립을 시작하세요.", "empty_cta": "포지션 개설", - "position_units": "{{units}}주", "positions_title": "포지션", "activity_title": "활동", "view_activity": "활동 보기", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 9e606f5776b..15075d9f275 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8512,7 +8512,6 @@ "empty": "Você não tem nenhuma posição", "empty_description": "Abra uma posição em ativos tokenizados do mundo real e comece a ganhar recompensas.", "empty_cta": "Abrir uma posição", - "position_units": "{{units}} ações", "positions_title": "Posições", "activity_title": "Atividade", "view_activity": "Ver atividade", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index f292c81431e..dc7d93ec134 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8512,7 +8512,6 @@ "empty": "У вас нет никаких позиций", "empty_description": "Начните зарабатывать вознаграждения, открыв позицию в токенизированных активах реального мира.", "empty_cta": "Открыть позицию", - "position_units": "{{units}} акции(-ий)", "positions_title": "Позиции", "activity_title": "Деятельность", "view_activity": "Смотреть активность", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index c5b4425bcbd..557ba86be90 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8512,7 +8512,6 @@ "empty": "Wala kang anumang posisyon", "empty_description": "Simulang kumita ng mga reward sa pamamagitan ng pagbubukas ng posisyon sa naka-token na mga asset sa totoong mundo.", "empty_cta": "Magbukas ng posisyon", - "position_units": "{{units}} (na) share", "positions_title": "Mga Posisyon", "activity_title": "Aktibidad", "view_activity": "Tingnan ang aktibidad", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 2a71fe184a4..5a67f03f591 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8512,7 +8512,6 @@ "empty": "Pozisyonunuz yok", "empty_description": "Tokenlaştırılmış gerçek dünya varlıklarında bir pozisyon açarak ödül kazanmaya başlayın.", "empty_cta": "Bir pozisyon açın", - "position_units": "{{units}} pay", "positions_title": "Pozisyonlar", "activity_title": "Aktivite", "view_activity": "Aktiviteyi görüntüle", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 6fc67e4c02d..8b0a9964789 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8512,7 +8512,6 @@ "empty": "Bạn không có bất kỳ vị thế nào", "empty_description": "Bắt đầu nhận phần thưởng bằng cách mở một vị thế trong tài sản thế giới thực được token hóa.", "empty_cta": "Mở vị thế", - "position_units": "{{units}} cổ phần", "positions_title": "Vị thế", "activity_title": "Hoạt động", "view_activity": "Xem hoạt động", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index f7a0a2db289..5d545f91847 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8512,7 +8512,6 @@ "empty": "您暂无任何持仓", "empty_description": "通过开立代币化现实世界资产的仓位,开始赚取奖励。", "empty_cta": "开仓", - "position_units": "{{units}} 份额", "positions_title": "头寸", "activity_title": "活动", "view_activity": "查看活动", diff --git a/package.json b/package.json index ae9cdbd7feb..10f326383ec 100644 --- a/package.json +++ b/package.json @@ -255,9 +255,9 @@ "@metamask/core-backend": "^6.2.0", "@metamask/delegation-controller": "^2.0.2", "@metamask/delegation-deployments": "^1.0.0", - "@metamask/design-system-react-native": "^0.22.0", + "@metamask/design-system-react-native": "^0.23.0", "@metamask/design-system-twrnc-preset": "^0.4.2", - "@metamask/design-tokens": "^8.3.0", + "@metamask/design-tokens": "^8.4.0", "@metamask/earn-controller": "^12.1.0", "@metamask/eip-5792-middleware": "^2.0.0", "@metamask/eip1193-permission-middleware": "^1.0.2", diff --git a/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts b/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts index 8b01944fde3..f9eb6fe2e3d 100644 --- a/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts +++ b/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts @@ -966,23 +966,41 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( // Safe Factory call - return proxy wallet address result = MOCK_RPC_RESPONSES.SAFE_FACTORY_RESULT; } else if ( - toAddress?.toLowerCase() === USDC_CONTRACT_ADDRESS.toLowerCase() + toAddress?.toLowerCase() === POLYGON_PUSD_TOKEN_ADDRESS.toLowerCase() ) { - // USDC contract call - check function selector + // pUSD contract call (post-CLOB-v1 migration: Predict balance lives in pUSD). + // Return the current global balance for balanceOf so the displayed Predict + // balance comes from pUSD, matching production state for v2 users. if (callData?.toLowerCase()?.startsWith('0x70a08231')) { // balanceOf(address) selector - return current global balance result = currentUSDCBalance; } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { - // allowance(address,address) selector - return max allowance (uint256 max) - // This indicates full allowance is granted + // allowance(address,address) selector - max allowance + result = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + } else { + result = currentUSDCBalance; + } + } else if ( + toAddress?.toLowerCase() === USDC_CONTRACT_ADDRESS.toLowerCase() + ) { + // Legacy Safe USDC.e contract call. Post-migration this balance is 0 + // so deposit/withdraw/claim/trade flows do not append the legacy sweep + // maintenance transactions during E2E. Allowances stay maxed so any + // unrelated reads still see the wallet as fully approved. + if (callData?.toLowerCase()?.startsWith('0x70a08231')) { + // balanceOf(address) selector - ABI-encoded 0 (no legacy balance to sweep) + result = MOCK_RPC_RESPONSES.ZERO_UINT256_RESULT; + } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { + // allowance(address,address) selector - max allowance result = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; } else if (callData?.toLowerCase()?.startsWith('0x6352211e')) { - // ownerOf(uint256) selector - return owner of the token + // ownerOf(uint256) selector result = '0x'; } else { - // Other USDC contract calls - return current global balance as fallback - result = currentUSDCBalance; + // Other legacy USDC.e contract calls default to ABI-encoded zero. + result = MOCK_RPC_RESPONSES.ZERO_UINT256_RESULT; } } else if ( toAddress?.toLowerCase() === MULTICALL_CONTRACT_ADDRESS.toLowerCase() @@ -1012,7 +1030,9 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( result = MOCK_RPC_RESPONSES.EMPTY_RESULT; } } else if (body?.method === 'eth_blockNumber') { - // Return current block number (dynamically updated to invalidate cache) + // Auto-advance so PendingTransactionTracker detects new blocks and + // polls eth_getTransactionReceipt, allowing relay deposits to confirm. + currentBlockNumber++; result = `0x${currentBlockNumber.toString(16)}`; } else if (body?.method === 'eth_getBalance') { result = MOCK_RPC_RESPONSES.ETH_BALANCE_RESULT; @@ -1475,7 +1495,7 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( return false; } - // Parse body to ensure this is a USDC balance call + // Parse body to ensure this is a pUSD balance call try { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : undefined; @@ -1485,9 +1505,9 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( const toAddress = body?.params?.[0]?.to?.toLowerCase(); const callData = body?.params?.[0]?.data; const isMatch = - toAddress === USDC_CONTRACT_ADDRESS.toLowerCase() && + toAddress === POLYGON_PUSD_TOKEN_ADDRESS.toLowerCase() && callData?.toLowerCase()?.startsWith('0x70a08231'); - // Only match USDC balanceOf calls + // Only match pUSD balanceOf calls (post-CLOB-v1 Predict balance source) return isMatch; } catch (error) { return false; @@ -1546,15 +1566,23 @@ export const POLYMARKET_POST_CASH_OUT_MOCKS = async (mockServer: Mockttp) => { // Verify the request matches cash-out order structure // Only check consistent fields - allow variable values for dynamic fields (salt, tokenId, amounts, signature, owner) + // CLOB v2 SELL order shape — see + // app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts + // (`buildProtocolUnsignedOrder`/`serializeProtocolRelayerOrder`). + // v2 orders have `timestamp`, `metadata`, `builder`; v1-only fields + // (`taker`, `nonce`, `feeRateBps`) were removed when CLOB v1 support + // was dropped, so this matcher must not check them. return ( order && (body.orderType === 'FOK' || body.orderType === 'FAK') && order.maker?.toLowerCase() === PROXY_WALLET_ADDRESS.toLowerCase() && order.signer?.toLowerCase() === USER_WALLET_ADDRESS.toLowerCase() && - order.taker === '0x0000000000000000000000000000000000000000' && order.expiration === '0' && - order.nonce === '0' && - typeof order.feeRateBps === 'string' && + typeof order.timestamp === 'string' && + typeof order.metadata === 'string' && + order.metadata.startsWith('0x') && + typeof order.builder === 'string' && + order.builder.startsWith('0x') && order.side === 'SELL' && order.signatureType === 2 && typeof order.salt === 'number' && @@ -1878,12 +1906,14 @@ export const POLYMARKET_WITHDRAW_BALANCE_LOAD_MOCKS = async ( try { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : undefined; - const isUSDCBalanceCall = + // Match pUSD balanceOf calls — post-CLOB-v1 migration the Predict + // balance lives in pUSD, so withdraw flow refreshes target pUSD. + const isPusdBalanceCall = body?.method === 'eth_call' && body?.params?.[0]?.to?.toLowerCase() === - USDC_CONTRACT_ADDRESS.toLowerCase(); + POLYGON_PUSD_TOKEN_ADDRESS.toLowerCase(); - return isUSDCBalanceCall; + return isPusdBalanceCall; } catch (error) { return false; } diff --git a/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts b/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts index 7cb1b5dfde0..90ed7c1309b 100644 --- a/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts +++ b/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts @@ -25,6 +25,9 @@ export const MOCK_RPC_RESPONSES = { // Post-claim USDC balance (48.16 USDC = 48,160,000 = 0x2de0300) POST_CLAIM_USDC_BALANCE_RESULT: POST_CLAIM_USDC_BALANCE_WEI, + ZERO_UINT256_RESULT: + '0x0000000000000000000000000000000000000000000000000000000000000000', + EMPTY_RESULT: '0x', // Mock approval result (true) diff --git a/tests/smoke/confirmations/transactions/transaction-pay.spec.ts b/tests/smoke/confirmations/transactions/transaction-pay.spec.ts index c8bf1c3a8cd..e92fd0ccb28 100644 --- a/tests/smoke/confirmations/transactions/transaction-pay.spec.ts +++ b/tests/smoke/confirmations/transactions/transaction-pay.spec.ts @@ -25,7 +25,10 @@ import ActivitiesView from '../../../page-objects/Transactions/ActivitiesView'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; describe(SmokeConfirmations('Transaction Pay'), () => { - it('deposits to predict balance', async () => { + // TODO: Re-enable once Predict deposit activity is stable again after the + // CLOB v2 migration work. + // eslint-disable-next-line jest/no-disabled-tests -- temporarily disabling a flaky Predict deposit activity assertion + it.skip('deposits to predict balance', async () => { await withFixtures( { fixture: new FixtureBuilder() diff --git a/tests/smoke/wallet/incoming-transactions.spec.ts b/tests/smoke/wallet/incoming-transactions.spec.ts index b5c46a49497..28bd9f8eb6a 100644 --- a/tests/smoke/wallet/incoming-transactions.spec.ts +++ b/tests/smoke/wallet/incoming-transactions.spec.ts @@ -104,16 +104,15 @@ function mockAccountsApi( transactions: Record[] = [], ): MockApiEndpoint { return { - urlEndpoint: new RegExp( - `^https://accounts\\.api\\.cx\\.metamask\\.io/v1/accounts/${DEFAULT_FIXTURE_ACCOUNT}/transactions\\?.*sortDirection=DESC`, - ), + urlEndpoint: + /^https:\/\/accounts\.api\.cx\.metamask\.io\/v4\/multiaccount\/transactions(\?.*)?$/, response: { data: transactions.length > 0 ? transactions : [RESPONSE_STANDARD_MOCK, RESPONSE_STANDARD_2_MOCK], pageInfo: { - count: 2, + count: transactions.length || 2, hasNextPage: false, }, }, @@ -126,12 +125,16 @@ function createAccountsTestSpecificMock( ): TestSpecificMock { return async (mockServer: Mockttp) => { const mock = mockAccountsApi(transactions); - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: mock.urlEndpoint, - response: mock.response, - responseCode: mock.responseCode, - }); + await setupMockRequest( + mockServer, + { + requestMethod: 'GET', + url: mock.urlEndpoint, + response: mock.response, + responseCode: mock.responseCode, + }, + 1000, + ); }; } @@ -184,7 +187,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { { fixture, restartDevice: true, - testSpecificMock: createAccountsTestSpecificMock(), + testSpecificMock: createAccountsTestSpecificMock([ + RESPONSE_STANDARD_MOCK, + ]), }, async () => { await loginToApp(); @@ -262,7 +267,7 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { ); }); - it('displays nothing if privacyMode is enabled', async () => { + it.skip('displays nothing if privacyMode is enabled', async () => { const fixture = new FixtureBuilder() .withAccountTreeController( EVM_ONLY_ACCOUNT_TREE as unknown as Partial, diff --git a/wdio/utils/generateTestId.js b/wdio/utils/generateTestId.js deleted file mode 100644 index a94995fc09c..00000000000 --- a/wdio/utils/generateTestId.js +++ /dev/null @@ -1,2 +0,0 @@ -export default (Platform, id) => ({ testID: id }); - diff --git a/yarn.lock b/yarn.lock index 787872a8cd3..2409d5e4fd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8326,11 +8326,11 @@ __metadata: languageName: node linkType: hard -"@metamask/design-system-react-native@npm:^0.22.0": - version: 0.22.0 - resolution: "@metamask/design-system-react-native@npm:0.22.0" +"@metamask/design-system-react-native@npm:^0.23.0": + version: 0.23.0 + resolution: "@metamask/design-system-react-native@npm:0.23.0" dependencies: - "@metamask/design-system-shared": "npm:^0.15.0" + "@metamask/design-system-shared": "npm:^0.16.0" fast-text-encoding: "npm:^1.0.6" react-native-jazzicon: "npm:^0.1.2" peerDependencies: @@ -8343,18 +8343,18 @@ __metadata: react-native-gesture-handler: ">=2.25.0" react-native-reanimated: ">=3.17.0" react-native-safe-area-context: ">=5.0.0" - checksum: 10/5deef0fbdb6871ae80e6ff85f40a402aba2b3ca8e224cb4cbe530d2bff436b6b1251323a9fd8422490bc24f3ec04377635bd39653d18f690068364d408bb9ea7 + checksum: 10/76c88e0cb8e263361eeb53b63c90dfc6718502012cc00cc76c18d2ec5e70520b63717ed66c03103c7846f8405934a15cb91e571e87eaa2255963c6657953a270 languageName: node linkType: hard -"@metamask/design-system-shared@npm:^0.15.0": - version: 0.15.0 - resolution: "@metamask/design-system-shared@npm:0.15.0" +"@metamask/design-system-shared@npm:^0.16.0": + version: 0.16.0 + resolution: "@metamask/design-system-shared@npm:0.16.0" dependencies: "@metamask/utils": "npm:^11.11.0" peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10/a900d9cf73eb2fb6d84cd8f29387530ea4dde4fca1d22f7d85b2f05a47b71ed92bc4e2893d7a9589f2804afa22bc34566fb3e88ed9bf40a4211507efb2028b4c + checksum: 10/3311b3ac9c2e24eb39d6fac9df34e2c2d2390e189ca1a71e335ea7a49f8bc423e2bc4676ce26b1a37e78f27b6ec8178016930c288711f363ca18cdff0a0d44be languageName: node linkType: hard @@ -8370,10 +8370,10 @@ __metadata: languageName: node linkType: hard -"@metamask/design-tokens@npm:^8.3.0": - version: 8.3.0 - resolution: "@metamask/design-tokens@npm:8.3.0" - checksum: 10/b8fc870792f1d66986dce1e2c0d71f291f64a2da3e6c9d74de69b0f814b6479b922420c80bf59d03d9f22072b004c413029517619569ec5c21345a5aedd4c882 +"@metamask/design-tokens@npm:^8.4.0": + version: 8.4.0 + resolution: "@metamask/design-tokens@npm:8.4.0" + checksum: 10/8127e4793e03e1ab547bba05f561bfe6455b5779c8a12043dc18395690eda4d43b51a8367d14ecf5b365d927946795ac8479c5a6a10d8a5def1192b89a7884a7 languageName: node linkType: hard @@ -35646,9 +35646,9 @@ __metadata: "@metamask/core-backend": "npm:^6.2.0" "@metamask/delegation-controller": "npm:^2.0.2" "@metamask/delegation-deployments": "npm:^1.0.0" - "@metamask/design-system-react-native": "npm:^0.22.0" + "@metamask/design-system-react-native": "npm:^0.23.0" "@metamask/design-system-twrnc-preset": "npm:^0.4.2" - "@metamask/design-tokens": "npm:^8.3.0" + "@metamask/design-tokens": "npm:^8.4.0" "@metamask/earn-controller": "npm:^12.1.0" "@metamask/eip-5792-middleware": "npm:^2.0.0" "@metamask/eip1193-permission-middleware": "npm:^1.0.2"