diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cd8693349e..ca472341dd8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -255,7 +255,8 @@ jobs: exit 0 fi mkdir -p ~/.ssh - echo "$TAP_AND_PAY_SDK_SSH_KEY" | base64 -d > ~/.ssh/tap_and_pay_key + echo "$TAP_AND_PAY_SDK_SSH_KEY" | base64 -d | tr -d '\r' > ~/.ssh/tap_and_pay_key + echo "" >> ~/.ssh/tap_and_pay_key chmod 600 ~/.ssh/tap_and_pay_key trap 'rm -f ~/.ssh/tap_and_pay_key' EXIT ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null diff --git a/.github/workflows/nightly-build-temp.yml b/.github/workflows/nightly-build-temp.yml deleted file mode 100644 index fbf4e79f44e..00000000000 --- a/.github/workflows/nightly-build-temp.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Nightly Build (temp) - -# Runs on a schedule (4 AM UTC) like the old nightly-temp-branch-sync. -# Each build creates its own ephemeral branch via create-build-branch.yml, -# so the persistent chore/temp-nightly branch is no longer needed. -# -# iOS builds are handled end-to-end by upload-to-testflight-temp.yml (build + upload). -# Android builds use create-build-branch.yml + build.yml (build artifacts only). -# -# rc depends on exp within each platform stream to ensure the external version -# service produces sequential numbers (N for exp, N+1 for rc). -# iOS and Android streams run in parallel; their version numbers will differ -# but remain unique, which is all TestFlight / Play Store require. - -on: - schedule: - # NOTE: Scheduled workflows ALWAYS run from the default branch (main) - - cron: '0 4 * * *' - workflow_dispatch: - -permissions: - contents: write - id-token: write - -jobs: - # ── iOS exp: build + TestFlight upload ───────────────────────────────── - ios-exp: - name: Nightly iOS exp - uses: ./.github/workflows/upload-to-testflight-temp.yml - with: - source_branch: main - environment: exp - testflight_group: 'MetaMask BETA & Release Candidates' - secrets: inherit - - # ── iOS rc: build + TestFlight upload (after exp for sequential versions) ─ - ios-rc: - name: Nightly iOS rc - needs: [ios-exp] - uses: ./.github/workflows/upload-to-testflight-temp.yml - with: - source_branch: main - environment: rc - testflight_group: 'MetaMask BETA & Release Candidates' - secrets: inherit - - # ── Android exp: ephemeral branch + build ────────────────────────────── - android-exp-branch: - uses: ./.github/workflows/create-build-branch.yml - with: - source_branch: main - secrets: inherit - - android-exp: - name: Nightly Android exp - needs: [android-exp-branch] - uses: ./.github/workflows/build.yml - with: - build_name: main-exp - platform: android - skip_version_bump: false - source_branch: ${{ needs.android-exp-branch.outputs.build_branch }} - secrets: inherit - - # ── Android rc: ephemeral branch + build (after exp for sequential versions) ─ - android-rc-branch: - needs: [android-exp] - uses: ./.github/workflows/create-build-branch.yml - with: - source_branch: main - secrets: inherit - - android-rc: - name: Nightly Android rc - needs: [android-rc-branch] - uses: ./.github/workflows/build.yml - with: - build_name: main-rc - platform: android - skip_version_bump: false - source_branch: ${{ needs.android-rc-branch.outputs.build_branch }} - secrets: inherit - - # ── Cleanup Android ephemeral branches ───────────────────────────────── - # iOS branches are cleaned up by upload-to-testflight-temp.yml internally. - cleanup: - name: Cleanup Android build branches - needs: [android-exp-branch, android-rc-branch, android-exp, android-rc] - if: always() - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.PR_TOKEN || github.token }} - - name: Delete ephemeral branches - env: - EXP_BRANCH: ${{ needs.android-exp-branch.outputs.build_branch }} - RC_BRANCH: ${{ needs.android-rc-branch.outputs.build_branch }} - run: | - for branch in "$EXP_BRANCH" "$RC_BRANCH"; do - if [ -n "$branch" ]; then - git push origin --delete "$branch" || true - echo "🧹 Deleted: $branch" - fi - done diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 04ea2bbe84a..34d3e768f68 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -1,211 +1,105 @@ name: Nightly Build -# Triggered by every push to chore/temp-nightly (which nightly-temp-branch-sync.yml -# force-pushes daily at 4 AM UTC to match main). -# Temporarily now this is pointing to test/temp-nightly instead of chore/temp-nightly. +# Runs on a schedule (4 AM UTC) like the old nightly-branch-sync. +# Each build creates its own ephemeral branch via create-build-branch.yml, +# so the persistent chore/temp-nightly branch is no longer needed. # -# [skip ci] commits (e.g. version bumps pushed via update-latest-build-version.yml) -# are automatically skipped by GitHub Actions, so this workflow will NOT -# double-trigger on those commits. +# iOS builds are handled end-to-end by upload-to-testflight.yml (build + upload). +# Android builds use create-build-branch.yml + build.yml (build artifacts only). # -# Version strategy: exp and rc builds share the same bundle ID (MetaMask) so -# TestFlight requires unique CFBundleVersion per upload. We call the external -# version generator once (→ version N for exp), then locally increment to N+1 -# for the rc build. Both builds run in parallel after their respective bumps. +# rc depends on exp within each platform stream to ensure the external version +# service produces sequential numbers (N for exp, N+1 for rc). +# iOS and Android streams run in parallel; their version numbers will differ +# but remain unique, which is all TestFlight / Play Store require. on: - push: - branches: - - test/temp-nightly + schedule: + # NOTE: Scheduled workflows ALWAYS run from the default branch (main) + - cron: '0 4 * * *' workflow_dispatch: -# contents: write required by build.yml update-build-version job (version bump commit push) permissions: contents: write id-token: write jobs: - bump-version-exp: - name: Bump build version (exp) - uses: ./.github/workflows/update-latest-build-version.yml - permissions: - contents: write - id-token: write + # ── iOS exp: build + TestFlight upload ───────────────────────────────── + ios-exp: + name: Nightly iOS exp + uses: ./.github/workflows/upload-to-testflight.yml with: - base-branch: ${{ github.ref_name }} + source_branch: main + environment: exp + testflight_group: 'MetaMask BETA & Release Candidates' secrets: inherit - bump-version-rc: - name: Bump build version (rc) - needs: [bump-version-exp] - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - commit-hash: ${{ steps.bump.outputs.commit-hash }} - build-version: ${{ steps.bump.outputs.build-version }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.bump-version-exp.outputs.commit-hash }} - fetch-depth: 0 - token: ${{ secrets.PR_TOKEN || github.token }} + # ── iOS rc: build + TestFlight upload (after exp for sequential versions) ─ + ios-rc: + name: Nightly iOS rc + needs: [ios-exp] + uses: ./.github/workflows/upload-to-testflight.yml + with: + source_branch: main + environment: rc + testflight_group: 'MetaMask BETA & Release Candidates' + secrets: inherit - - name: Increment version for RC build - id: bump - env: - EXP_VERSION: ${{ needs.bump-version-exp.outputs.build-version }} - HEAD_REF: ${{ github.ref_name }} - run: | - RC_VERSION=$((EXP_VERSION + 1)) - echo "Exp version: $EXP_VERSION → RC version: $RC_VERSION" - ./scripts/set-build-version.sh "$RC_VERSION" - git config user.name metamaskbot - git config user.email metamaskbot@users.noreply.github.com - git add bitrise.yml package.json ios/MetaMask.xcodeproj/project.pbxproj android/app/build.gradle - git commit -m "[skip ci] Bump version number to ${RC_VERSION} (nightly rc)" - git push origin HEAD:"$HEAD_REF" --force-with-lease - echo "commit-hash=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "build-version=$RC_VERSION" >> "$GITHUB_OUTPUT" + # ── Android exp: ephemeral branch + build ────────────────────────────── + android-exp-branch: + uses: ./.github/workflows/create-build-branch.yml + with: + source_branch: main + secrets: inherit - build-exp: - name: Nightly exp build (main-exp) - needs: [bump-version-exp] + android-exp: + name: Nightly Android exp + needs: [android-exp-branch] uses: ./.github/workflows/build.yml with: build_name: main-exp - platform: both - skip_version_bump: true - source_branch: ${{ needs.bump-version-exp.outputs.commit-hash }} + platform: android + skip_version_bump: false + source_branch: ${{ needs.android-exp-branch.outputs.build_branch }} secrets: inherit - build-rc: - name: Nightly RC build (main-rc) - needs: [bump-version-rc] + # ── Android rc: ephemeral branch + build (after exp for sequential versions) ─ + android-rc-branch: + needs: [android-exp] + uses: ./.github/workflows/create-build-branch.yml + with: + source_branch: main + secrets: inherit + + android-rc: + name: Nightly Android rc + needs: [android-rc-branch] uses: ./.github/workflows/build.yml with: build_name: main-rc - platform: both - skip_version_bump: true - source_branch: ${{ needs.bump-version-rc.outputs.commit-hash }} + platform: android + skip_version_bump: false + source_branch: ${{ needs.android-rc-branch.outputs.build_branch }} secrets: inherit - upload-exp-testflight: - name: Upload exp to TestFlight - needs: [build-exp] - runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl - environment: apple - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Ruby (iOS) - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 - with: - ruby-version: '3.2.9' - working-directory: ios - bundler-cache: true - - - name: Download iOS IPA artifact - uses: actions/download-artifact@v4 - with: - name: ios-ipa-main-exp - - - name: Find IPA path - id: ipa - run: | - IPA=$(find . -name '*.ipa' -type f | head -1) - if [ -z "$IPA" ]; then - echo "::error::No .ipa file found in artifact" - exit 1 - fi - case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac - echo "path=$ABS" >> "$GITHUB_OUTPUT" - - - name: Setup App Store Connect API Key - run: | - bash scripts/setup-app-store-connect-api-key.sh \ - "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ - "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ - "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" - env: - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} - APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} - - - name: Upload to TestFlight - run: | - bash scripts/upload-to-testflight.sh \ - "github_actions_main-exp" \ - "${{ github.ref_name }}" \ - "${{ steps.ipa.outputs.path }}" \ - "MetaMask BETA & Release Candidates" - - - name: Cleanup API Key - if: always() - run: | - rm -f ios/AuthKey.p8 - echo "🧹 Cleaned up API key file" - - upload-rc-testflight: - name: Upload RC to TestFlight - needs: [build-rc] - runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl - environment: apple + # ── Cleanup Android ephemeral branches ───────────────────────────────── + # iOS branches are cleaned up by upload-to-testflight.yml internally. + cleanup: + name: Cleanup Android build branches + needs: [android-exp-branch, android-rc-branch, android-exp, android-rc] + if: always() + runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Ruby (iOS) - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 - with: - ruby-version: '3.2.9' - working-directory: ios - bundler-cache: true - - - name: Download iOS IPA artifact - uses: actions/download-artifact@v4 + - uses: actions/checkout@v4 with: - name: ios-ipa-main-rc - - - name: Find IPA path - id: ipa - run: | - IPA=$(find . -name '*.ipa' -type f | head -1) - if [ -z "$IPA" ]; then - echo "::error::No .ipa file found in artifact" - exit 1 - fi - case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac - echo "path=$ABS" >> "$GITHUB_OUTPUT" - - - name: Setup App Store Connect API Key - run: | - bash scripts/setup-app-store-connect-api-key.sh \ - "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ - "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ - "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" + token: ${{ secrets.PR_TOKEN || github.token }} + - name: Delete ephemeral branches env: - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} - APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} - - - name: Upload to TestFlight - run: | - # Group arg is required as positional placeholder for the 5th arg (distribute_external=false). - # With distribute_external=false the build is uploaded but NOT distributed to external testers. - bash scripts/upload-to-testflight.sh \ - "github_actions_main-rc" \ - "${{ github.ref_name }}" \ - "${{ steps.ipa.outputs.path }}" \ - "MetaMask BETA & Release Candidates" - - - name: Cleanup API Key - if: always() + EXP_BRANCH: ${{ needs.android-exp-branch.outputs.build_branch }} + RC_BRANCH: ${{ needs.android-rc-branch.outputs.build_branch }} run: | - rm -f ios/AuthKey.p8 - echo "🧹 Cleaned up API key file" + for branch in "$EXP_BRANCH" "$RC_BRANCH"; do + if [ -n "$branch" ]; then + git push origin --delete "$branch" || true + echo "🧹 Deleted: $branch" + fi + done diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 5eac948db24..f21db544c44 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -82,6 +82,7 @@ jobs: echo "| **Update version** | ${UPDATE_MESSAGE} |" echo "| **Target version** | ${BASE_BRANCH_REF} |" echo "| **Environment** | ${TARGET_CHANNEL} |" + echo "| **Platform** | ${OTA_PUSH_PLATFORM} |" echo "| **Target commit** | ${{ needs.validate-pr.outputs.commit_sha }} |" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/upload-to-testflight-temp.yml b/.github/workflows/upload-to-testflight-temp.yml deleted file mode 100644 index 64d57f920a3..00000000000 --- a/.github/workflows/upload-to-testflight-temp.yml +++ /dev/null @@ -1,217 +0,0 @@ -name: Upload to TestFlight (temp) - -# Dedicated workflow to build iOS (via build.yml) and upload to TestFlight. -# All TestFlight logic lives here; build.yml only builds and uploads artifacts. -# -on: - workflow_call: - inputs: - source_branch: - description: 'Branch, tag, or SHA to build' - required: true - type: string - environment: - description: 'Build environment / track (exp, beta, rc)' - required: true - type: string - testflight_group: - description: 'TestFlight external testing group' - required: false - type: string - default: 'MetaMask BETA & Release Candidates' - workflow_dispatch: - inputs: - source_branch: - description: 'Branch, tag, or SHA to build' - required: true - type: string - default: 'main' - environment: - description: 'Build environment / track' - required: true - type: choice - options: - - exp - - beta - - rc - default: rc - testflight_group: - description: 'TestFlight external testing group' - required: true - type: choice - default: 'MetaMask BETA & Release Candidates' - options: - - 'MetaMask BETA & Release Candidates' - - 'MM Card Team' - - 'Ramp Provider Testing' - -# contents: write required by build.yml update-build-version job (version bump) -permissions: - contents: write - id-token: write - -jobs: - # Create a temporary branch so the version-bump commit can be pushed without - # hitting branch-protection rules on the source branch (e.g. main). - prepare-build-branch: - uses: ./.github/workflows/create-build-branch.yml - with: - source_branch: ${{ inputs.source_branch }} - secrets: inherit - - build: - name: Build iOS (${{ inputs.environment || 'rc' }}) - needs: [prepare-build-branch] - uses: ./.github/workflows/build.yml - with: - build_name: main-${{ inputs.environment || 'rc' }} - platform: ios - skip_version_bump: false - source_branch: ${{ needs.prepare-build-branch.outputs.build_branch }} - secrets: inherit - - testflight-upload-summary: - name: TestFlight upload summary - needs: [build, prepare-build-branch] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ needs.prepare-build-branch.outputs.build_branch }} - - name: Display TestFlight upload summary - run: | - BUILD_VERSION=$(node -p "require('./package.json').version") - BUILD_NUMBER=$(awk '/versionCode/{print $2}' android/app/build.gradle) - { - echo "### 📲 TestFlight Upload" - echo "" - echo "| Field | Value |" - echo "| --- | --- |" - echo "| **Source branch** | \`${{ inputs.source_branch }}\` |" - echo "| **Build branch** | \`${{ needs.prepare-build-branch.outputs.build_branch }}\` |" - echo "| **Build name** | \`main-${{ inputs.environment || 'rc' }}\` |" - echo "| **Build version** | \`${BUILD_VERSION}\` |" - echo "| **Build number** | \`${BUILD_NUMBER}\` |" - echo "| **TestFlight group** | ${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }} |" - echo "| **Workflow ref** | \`${{ github.ref_name }}\` (required for AWS) |" - } >> "$GITHUB_STEP_SUMMARY" - - # Pulls App Store Connect API keys from AWS Secrets Manager (OIDC). - # Workflow must run from main; build uses the temporary build branch. - upload-ios-testflight: - name: Upload iOS to TestFlight - needs: [build, testflight-upload-summary] - runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Ruby (iOS) - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 - with: - ruby-version: '3.2.9' - working-directory: ios - bundler-cache: true - - - name: Download iOS IPA artifact - uses: actions/download-artifact@v4 - with: - name: ios-ipa-main-${{ inputs.environment || 'rc' }} - - - name: Find IPA path - id: ipa - run: | - IPA=$(find . -name '*.ipa' -type f | head -1) - if [ -z "$IPA" ]; then - echo "::error::No .ipa file found in artifact" - exit 1 - fi - case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac - echo "path=$ABS" >> "$GITHUB_OUTPUT" - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_ROLE_APPLE_TESTFLIGHT }} - aws-region: 'us-east-2' - - - name: Fetch Apple API keys from AWS Secrets Manager - run: | - echo "🔐 Fetching App Store Connect API keys from Secrets Manager..." - secret_id="metamask-mobile-main-apple-api-keys" - secret_json=$(aws secretsmanager get-secret-value \ - --region 'us-east-2' \ - --secret-id "$secret_id" \ - --query SecretString \ - --output text) - - for key in APP_STORE_CONNECT_API_KEY_ISSUER_ID APP_STORE_CONNECT_API_KEY_KEY_ID; do - value=$(echo "$secret_json" | jq -r --arg k "$key" '.[$k] // empty') - if [ -z "$value" ]; then - echo "::error::Missing key in secret: $key" - exit 1 - fi - echo "::add-mask::$value" - echo "${key}=${value}" >> "$GITHUB_ENV" - done - - key=APP_STORE_CONNECT_API_KEY_KEY_CONTENT - value=$(echo "$secret_json" | jq -r --arg k "$key" '.[$k] // empty') - if [ -z "$value" ]; then - echo "::error::Missing key in secret: $key" - exit 1 - fi - while IFS= read -r line || [ -n "$line" ]; do - [ -n "$line" ] && echo "::add-mask::$line" - done <<< "$(printf '%s\n' "$value")" - - delim="APPLEP8$(openssl rand -hex 16)" - { - printf '%s<<%s\n' "$key" "$delim" - printf '%s\n' "$value" - printf '%s\n' "$delim" - } >> "$GITHUB_ENV" - - echo "✅ Apple API keys loaded from AWS" - - - name: Setup App Store Connect API Key - run: | - bash scripts/setup-app-store-connect-api-key.sh \ - "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ - "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ - "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" - - - name: Upload to TestFlight - run: | - bash scripts/upload-to-testflight.sh \ - "github_actions_main-${{ inputs.environment || 'rc' }}" \ - "${{ inputs.source_branch }}" \ - "${{ steps.ipa.outputs.path }}" \ - "${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }}" - - - name: Cleanup API Key - if: always() - run: | - rm -f ios/AuthKey.p8 - echo "🧹 Cleaned up API key file" - - cleanup-build-branch: - name: Cleanup build branch - needs: [prepare-build-branch, upload-ios-testflight] - if: always() - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.PR_TOKEN || github.token }} - - name: Delete temporary build branch - env: - BRANCH: ${{ needs.prepare-build-branch.outputs.build_branch }} - run: | - if [ -n "$BRANCH" ]; then - git push origin --delete "$BRANCH" || true - echo "🧹 Deleted build branch: $BRANCH" - fi diff --git a/.github/workflows/upload-to-testflight.yml b/.github/workflows/upload-to-testflight.yml index cc754779d79..fd295e33100 100644 --- a/.github/workflows/upload-to-testflight.yml +++ b/.github/workflows/upload-to-testflight.yml @@ -4,6 +4,21 @@ name: Upload to TestFlight # All TestFlight logic lives here; build.yml only builds and uploads artifacts. # on: + workflow_call: + inputs: + source_branch: + description: 'Branch, tag, or SHA to build' + required: true + type: string + environment: + description: 'Build environment / track (exp, beta, rc)' + required: true + type: string + testflight_group: + description: 'TestFlight external testing group' + required: false + type: string + default: 'MetaMask BETA & Release Candidates' workflow_dispatch: inputs: source_branch: @@ -36,42 +51,54 @@ permissions: id-token: write jobs: + # Create a temporary branch so the version-bump commit can be pushed without + # hitting branch-protection rules on the source branch (e.g. main). + prepare-build-branch: + uses: ./.github/workflows/create-build-branch.yml + with: + source_branch: ${{ inputs.source_branch }} + secrets: inherit + build: name: Build iOS (${{ inputs.environment || 'rc' }}) + needs: [prepare-build-branch] uses: ./.github/workflows/build.yml with: build_name: main-${{ inputs.environment || 'rc' }} platform: ios skip_version_bump: false - source_branch: ${{ inputs.source_branch }} + source_branch: ${{ needs.prepare-build-branch.outputs.build_branch }} secrets: inherit testflight-upload-summary: name: TestFlight upload summary - needs: [build] + needs: [build, prepare-build-branch] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ inputs.source_branch }} + ref: ${{ needs.prepare-build-branch.outputs.build_branch }} - name: Display TestFlight upload summary run: | BUILD_VERSION=$(node -p "require('./package.json').version") + BUILD_NUMBER=$(awk '/versionCode/{print $2}' android/app/build.gradle) { echo "### 📲 TestFlight Upload" echo "" echo "| Field | Value |" echo "| --- | --- |" - echo "| **Workflow ref** | ${{ github.ref_name }} (required for AWS) |" - echo "| **Source branch** | ${{ inputs.source_branch }} |" - echo "| **Build name** | main-${{ inputs.environment || 'rc' }} |" - echo "| **Build version** | ${BUILD_VERSION} |" + echo "| **Source branch** | \`${{ inputs.source_branch }}\` |" + echo "| **Build branch** | \`${{ needs.prepare-build-branch.outputs.build_branch }}\` |" + echo "| **Build name** | \`main-${{ inputs.environment || 'rc' }}\` |" + echo "| **Build version** | \`${BUILD_VERSION}\` |" + echo "| **Build number** | \`${BUILD_NUMBER}\` |" echo "| **TestFlight group** | ${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }} |" + echo "| **Workflow ref** | \`${{ github.ref_name }}\` (required for AWS) |" } >> "$GITHUB_STEP_SUMMARY" # Pulls App Store Connect API keys from AWS Secrets Manager (OIDC). - # Workflow must run from main; build uses inputs.source_branch. + # Workflow must run from main; build uses the temporary build branch. upload-ios-testflight: name: Upload iOS to TestFlight needs: [build, testflight-upload-summary] @@ -170,3 +197,21 @@ jobs: run: | rm -f ios/AuthKey.p8 echo "🧹 Cleaned up API key file" + + cleanup-build-branch: + name: Cleanup build branch + needs: [prepare-build-branch, upload-ios-testflight] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.PR_TOKEN || github.token }} + - name: Delete temporary build branch + env: + BRANCH: ${{ needs.prepare-build-branch.outputs.build_branch }} + run: | + if [ -n "$BRANCH" ]; then + git push origin --delete "$BRANCH" || true + echo "🧹 Deleted build branch: $BRANCH" + fi diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 17f756c31ad..f2e85291c06 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -364,7 +364,7 @@ const DetectedTokensFlow = () => ( @@ -733,7 +733,7 @@ const MultichainAccountDetails = () => { > { > { /> { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -20,6 +36,13 @@ jest.mock('@react-navigation/native', () => { navigate: mockNavigate, setOptions: jest.fn(), }), + useRoute: () => ({ + key: '1', + name: 'params', + params: { + evmTxMeta: mockTx, + }, + }), }; }); @@ -28,29 +51,6 @@ describe('BlockExplorersModal', () => { jest.clearAllMocks(); }); - const mockTx = { - id: 'test-tx-id', - chainId: '0x1', - hash: '0x123', - networkClientId: 'mainnet', - time: Date.now(), - txParams: { - from: '0x123', - to: '0x456', - value: '0x0', - data: '0x', - }, - status: TransactionStatus.submitted, - } as TransactionMeta; - - const mockProps = { - route: { - params: { - evmTxMeta: mockTx, - }, - }, - }; - const mockState = { ...initialState, bridge: { @@ -71,7 +71,7 @@ describe('BlockExplorersModal', () => { it('should render without crashing', () => { const { getByText } = renderScreen( - () => , + () => , { name: Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER, }, @@ -82,7 +82,7 @@ describe('BlockExplorersModal', () => { it('should display both source and destination chain block explorer buttons', () => { const { getAllByText } = renderScreen( - () => , + () => , { name: Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER, }, @@ -123,7 +123,7 @@ describe('BlockExplorersModal', () => { }; const { getAllByText } = renderScreen( - () => , + () => , { name: Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER, }, @@ -135,7 +135,7 @@ describe('BlockExplorersModal', () => { it('should navigate to webview when source chain explorer button is pressed', () => { const { getAllByText } = renderScreen( - () => , + () => , { name: Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER, }, @@ -155,7 +155,7 @@ describe('BlockExplorersModal', () => { it('should navigate to webview when destination chain explorer button is pressed', () => { const { getByText } = renderScreen( - () => , + () => , { name: Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER, }, diff --git a/app/components/UI/Bridge/components/TransactionDetails/BlockExplorersModal.tsx b/app/components/UI/Bridge/components/TransactionDetails/BlockExplorersModal.tsx index c3844baef6f..1a1b250f090 100644 --- a/app/components/UI/Bridge/components/TransactionDetails/BlockExplorersModal.tsx +++ b/app/components/UI/Bridge/components/TransactionDetails/BlockExplorersModal.tsx @@ -18,7 +18,7 @@ import Badge, { BadgeVariant, } from '../../../../../component-library/components/Badges/Badge'; import { Theme } from '../../../../../util/theme/models'; -import { useNavigation } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; @@ -36,21 +36,19 @@ const styleSheet = (params: { theme: Theme }) => }, }); -interface BlockExplorersModalProps { - route: { - params: { - evmTxMeta?: TransactionMeta; - multiChainTx?: Transaction; - }; - }; +interface BlockExplorersModalRouteParams { + evmTxMeta?: TransactionMeta; + multiChainTx?: Transaction; } -const BlockExplorersModal = (props: BlockExplorersModalProps) => { +const BlockExplorersModal = () => { const navigation = useNavigation(); + const route = + useRoute>(); const { styles } = useStyles(styleSheet, {}); - const evmTxMeta = props.route.params.evmTxMeta; - const multiChainTx = props.route.params.multiChainTx; + const evmTxMeta = route.params.evmTxMeta; + const multiChainTx = route.params.multiChainTx; const { bridgeTxHistoryItem } = useBridgeTxHistoryData({ evmTxMeta, diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsDisclaimerBottomSheet.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsDisclaimerBottomSheet.test.tsx new file mode 100644 index 00000000000..01b08d5e69f --- /dev/null +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsDisclaimerBottomSheet.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MarketInsightsDisclaimerBottomSheet from './MarketInsightsDisclaimerBottomSheet'; + +jest.mock('@metamask/design-system-react-native', () => { + const ReactLib = jest.requireActual('react'); + const { View: MockView, Text: MockText } = jest.requireActual('react-native'); + const actual = jest.requireActual('@metamask/design-system-react-native'); + + return { + ...actual, + BottomSheet: ReactLib.forwardRef( + ( + { + children, + onClose: onCloseProp, + }: { children: React.ReactNode; onClose?: () => void }, + ref: React.Ref<{ + onOpenBottomSheet: () => void; + onCloseBottomSheet: () => void; + }>, + ) => { + ReactLib.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + // Simulate animation completion by immediately invoking the onClose prop + onCloseBottomSheet: jest.fn().mockImplementation(() => { + onCloseProp?.(); + }), + })); + return {children}; + }, + ), + BottomSheetHeader: ({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) => ( + + {children} + {onClose && ( + + )} + + ), + }; +}); + +describe('MarketInsightsDisclaimerBottomSheet', () => { + it('calls onClose when the Got it button is pressed', () => { + const onClose = jest.fn(); + + const { getByText } = renderWithProvider( + , + ); + + fireEvent.press(getByText('Got it')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders the disclaimer title and body text', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('AI generated content')).toBeOnTheScreen(); + expect( + getByText(/This summary is AI-generated by a third party/), + ).toBeOnTheScreen(); + }); + + it('does not call onClose before the button is pressed', () => { + const onClose = jest.fn(); + + renderWithProvider( + , + ); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsDisclaimerBottomSheet.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsDisclaimerBottomSheet.tsx new file mode 100644 index 00000000000..d591e97de66 --- /dev/null +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsDisclaimerBottomSheet.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useRef } from 'react'; +import { Modal } from 'react-native'; +import { + BottomSheet, + BottomSheetHeader, + BottomSheetHeaderVariant, + Box, + Button, + ButtonSize, + ButtonVariant, + Text, + TextColor, + TextVariant, + type BottomSheetRef, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; + +interface MarketInsightsDisclaimerBottomSheetProps { + isVisible: boolean; + onClose: () => void; +} + +const MarketInsightsDisclaimerBottomSheet: React.FC< + MarketInsightsDisclaimerBottomSheetProps +> = ({ isVisible, onClose }) => { + const bottomSheetRef = useRef(null); + + const handleClose = useCallback(() => { + bottomSheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('market_insights.disclaimer_modal.title')} + + + + + {strings('market_insights.disclaimer_modal.body')} + + + + + + + + + ); +}; + +export default MarketInsightsDisclaimerBottomSheet; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx index 9a878cdfcf3..e1e471d0ee4 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx @@ -49,6 +49,39 @@ jest.mock('./AnimatedGradientBorder', () => ({ MockAnimatedGradientBorder(props), })); +let capturedOnSlideStart: (() => void) | null = null; +jest.mock('./SlidingTextCarousel', () => { + const { View, Text } = jest.requireActual('react-native'); + return ({ + texts, + onSlideStart, + }: { + texts: string[]; + onSlideStart?: () => void; + }) => { + capturedOnSlideStart = onSlideStart ?? null; + return ( + + {texts[0]} + + ); + }; +}); + +let capturedDisclaimerProps: { + isVisible: boolean; + onClose: () => void; +} | null = null; +jest.mock('./MarketInsightsDisclaimerBottomSheet', () => { + const { View } = jest.requireActual('react-native'); + return (props: { isVisible: boolean; onClose: () => void }) => { + capturedDisclaimerProps = props; + return props.isVisible ? ( + + ) : null; + }; +}); + /** * Finds the first node whose `onLayout` is not a Jest mock (skips * `useViewportTracking`'s mocked `onLayout`) so card `handleLayout` can be fired. @@ -92,6 +125,8 @@ describe('MarketInsightsEntryCard', () => { beforeEach(() => { jest.clearAllMocks(); capturedOnVisible = null; + capturedOnSlideStart = null; + capturedDisclaimerProps = null; jest.mocked(useAnalytics).mockReturnValue( createMockUseAnalyticsHook({ trackEvent: mockTrackEvent, @@ -297,4 +332,68 @@ describe('MarketInsightsEntryCard', () => { expect(getAnimationKey()).toBe(1); }); + + it('opens the disclaimer sheet when the info button is pressed', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect(capturedDisclaimerProps?.isVisible).toBe(false); + + fireEvent.press(getByTestId('market-insights-info-button')); + + expect(capturedDisclaimerProps?.isVisible).toBe(true); + }); + + it('closes the disclaimer sheet when onClose is called', () => { + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('market-insights-info-button')); + expect(capturedDisclaimerProps?.isVisible).toBe(true); + + act(() => { + capturedDisclaimerProps?.onClose(); + }); + + expect(capturedDisclaimerProps?.isVisible).toBe(false); + }); + + it('increments the border animation key when a carousel slide starts', () => { + MockAnimatedGradientBorder.mockClear(); + + renderWithProvider( + , + ); + + const getAnimationKey = () => { + const calls = MockAnimatedGradientBorder.mock.calls; + const lastCall = calls[calls.length - 1]; + return (lastCall[0] as Record).animationKey; + }; + + expect(getAnimationKey()).toBe(0); + + act(() => { + capturedOnSlideStart?.(); + }); + + expect(getAnimationKey()).toBe(1); + }); }); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index ee6e362471a..f135310b94c 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -3,6 +3,10 @@ import { BoxAlignItems, BoxFlexDirection, FontWeight, + Icon, + IconColor, + IconName, + IconSize, Text, TextColor, TextVariant, @@ -10,7 +14,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import MaskedView from '@react-native-masked-view/masked-view'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, TouchableOpacity, View, Pressable } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import { strings } from '../../../../../../locales/i18n'; import AiSVG from '../../../../../component-library/components/Icons/Icon/assets/ai.svg'; @@ -25,6 +29,7 @@ import { useViewportTracking } from '../../hooks/useViewportTracking'; import { AnimatedGradientBorder } from './AnimatedGradientBorder'; import { VISIBILITY_THRESHOLD } from './AnimatedGradientBorder.constants'; import type { MarketInsightsEntryCardProps } from './MarketInsightsEntryCard.types'; +import MarketInsightsDisclaimerBottomSheet from './MarketInsightsDisclaimerBottomSheet'; import SlidingTextCarousel from './SlidingTextCarousel'; import { CHROME_GRADIENT_HEAD, @@ -34,7 +39,7 @@ import { } from './constants'; const ARROW_ICON_SIZE = 16; -const SPARKLE_SIZE = 16; +const SPARKLE_SIZE = 20; const styles = StyleSheet.create({ gradientTextMask: { @@ -138,6 +143,15 @@ const MarketInsightsEntryCard: React.FC = ({ } | null>(null); const [borderAnimationKey, setBorderAnimationKey] = useState(0); + const [isDisclaimerVisible, setIsDisclaimerVisible] = useState(false); + + const handleOpenDisclaimer = useCallback(() => { + setIsDisclaimerVisible(true); + }, []); + + const handleCloseDisclaimer = useCallback(() => { + setIsDisclaimerVisible(false); + }, []); // Derive a stable key from actual text content so the memo doesn't bust // when the parent passes a new report object with identical data. @@ -207,69 +221,87 @@ const MarketInsightsEntryCard: React.FC = ({ ); return ( - - - - - - {/* Title row */} + <> + + - - {strings('market_insights.title')} - - - - {/* Body text: rotating trend descriptions */} - + {/* Title row */} + + + {strings('market_insights.title')} + + + - {/* Footer disclaimer */} - - - + + {/* Footer disclaimer */} + - {strings('market_insights.footer_disclaimer')} - {' • '} - {timeAgo} - + + + {strings('market_insights.card_footer_disclaimer')} + {' • '} + {timeAgo} + + + + + - - - + + + + ); }; diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 2257064f3d1..f077e745c2f 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -44,9 +44,6 @@ import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ScreenComponent = React.ComponentType; - const styles = StyleSheet.create({ container: { flex: 1, @@ -65,18 +62,14 @@ function getRedesignedConfirmationsHeaderOptions({ : { header: () => null }; } -const PerpsConfirmScreen = ( - props: React.ComponentProps & { - route: RouteProp; - }, -) => { - const params = +const PerpsConfirmScreen = () => { + const { params } = useRoute>(); const showPerpsHeader = - params?.params?.showPerpsHeader ?? + params?.showPerpsHeader ?? CONFIRMATION_HEADER_CONFIG.DefaultShowPerpsHeader; - return ; + return ; }; const PerpsModalStack = () => { @@ -421,7 +414,7 @@ const PerpsScreenStack = () => { getRedesignedConfirmationsHeaderOptions(route.params) } diff --git a/app/components/UI/Predict/README.md b/app/components/UI/Predict/README.md index 5c910234880..0473dec4942 100644 --- a/app/components/UI/Predict/README.md +++ b/app/components/UI/Predict/README.md @@ -174,20 +174,26 @@ Flow logic (deposit → order chaining, error handling, state transitions) lives ## Active Order Lifecycle -The `activeBuyOrder` in `PredictControllerState` tracks the full lifecycle of a single buy order from preview to completion. Only one order can be active at a time. All state transitions are owned by `PredictController` methods — hooks react to state changes via effects rather than driving transitions themselves. +The `activeBuyOrders` map in `PredictControllerState` tracks the full lifecycle of buy orders **per account address**. Each address can have at most one active order at a time. All state transitions are owned by `PredictController` methods — hooks react to state changes via effects rather than driving transitions themselves. + +The active order **persists across navigation**. When a user places a deposit-and-order bet and navigates away, the order state (DEPOSITING, PLACING_ORDER) is preserved. The user cannot place a second order while one is in-flight — the UI blocks interaction via `isPlacingOrder`. When the background order completes, `onPlaceOrderSuccess()` resets the entry to PREVIEW (never null). When the active order enters `PAY_WITH_ANY_TOKEN` and `placeOrder()` is called, the controller stores the preview and analytics in an in-memory `pendingOrderPreviews` map keyed by `transactionId`. After the deposit transaction confirms, `handleTransactionSideEffects()` looks up the stored preview and automatically calls `placeOrder()` to complete the order. ### State Shape ```typescript -activeBuyOrder: { - transactionId?: string; // Transaction ID linking deposit to order (for deposit-and-order flow) - state: ActiveOrderState; // Current lifecycle state - error?: string; // Error message from failed operations -} | null; +activeBuyOrders: { + [address: string]: { + transactionId?: string; // Transaction ID linking deposit to order (deposit-and-order flow only) + state: ActiveOrderState; // Current lifecycle state + error?: string; // Error message from failed operations + }; +}; ``` +Entries are lazily created by `initPayWithAnyToken()` on first buy screen mount for a given address. Default state is `{}` (empty map). The selector `selectPredictActiveBuyOrder` resolves the current account address to read the correct entry. + ### ActiveOrderState ```typescript @@ -196,7 +202,7 @@ enum ActiveOrderState { PAY_WITH_ANY_TOKEN = 'pay_with_any_token', // External token selected, deposit-and-order tx prepared in background DEPOSITING = 'depositing', // Deposit transaction in progress (set by placeOrder when state is PAY_WITH_ANY_TOKEN) PLACING_ORDER = 'placing_order', // Order submission in flight - SUCCESS = 'success', // Order completed, about to dismiss + SUCCESS = 'success', // Order completed, about to reset } ``` @@ -204,7 +210,7 @@ enum ActiveOrderState { ```mermaid stateDiagram-v2 - [*] --> PREVIEW: initPayWithAnyToken() + [*] --> PREVIEW: initPayWithAnyToken() [creates entry if absent] PREVIEW --> PAY_WITH_ANY_TOKEN: selectPaymentToken(external token) PAY_WITH_ANY_TOKEN --> PREVIEW: selectPaymentToken(balance token) @@ -213,38 +219,50 @@ stateDiagram-v2 PAY_WITH_ANY_TOKEN --> DEPOSITING: placeOrder() [external token selected] DEPOSITING --> PLACING_ORDER: handleTransactionSideEffects(depositAndOrder confirmed) → placeOrder() - DEPOSITING --> PREVIEW: handleTransactionSideEffects(depositAndOrder failed) [sets error, retries initPayWithAnyToken] + DEPOSITING --> PAY_WITH_ANY_TOKEN: handleTransactionSideEffects(depositAndOrder failed) [sets error, retries initPayWithAnyToken] PLACING_ORDER --> SUCCESS: placeOrder() succeeds PLACING_ORDER --> PREVIEW: placeOrder() fails [sets error, clears payment token, retries initPayWithAnyToken] - SUCCESS --> [*]: onPlaceOrderEnd() + navigation pop + SUCCESS --> PREVIEW: onPlaceOrderSuccess() [resets to initial state] ``` Notes: -- Back navigation or approval rejection triggers `onPlaceOrderEnd()`, which clears the active order, payment token, and deposit preview. -- Deposit failure resets to `PREVIEW`, stores the error on `activeBuyOrder.error`, clears `transactionId`, and automatically retries `initPayWithAnyToken()`. +- The active order is **never set to null** during normal flows. On SUCCESS, `onPlaceOrderSuccess()` resets the entry to `{ state: PREVIEW }` instead of removing it. On navigation back, `beforeRemove` clears only the `transactionId` (via `clearActiveOrderTransactionId()`) and rejects pending approvals — it does not clear the order entry. +- Deposit failure resets to `PAY_WITH_ANY_TOKEN`, stores the error on `activeBuyOrders[address].error`, clears `transactionId`, and automatically retries `initPayWithAnyToken()`. - Order failure resets to `PREVIEW`, stores the error, clears `selectedPaymentToken`, and if a `transactionId` was present, clears it and retries `initPayWithAnyToken()`. -- The `transitionEnd` listener in `usePredictBuyActions` triggers `initPayWithAnyToken()` once on initial mount to prepare the deposit-and-order batch when an external token is selected. +- The `transitionEnd` listener in `usePredictBuyActions` triggers `resetSelectedPaymentToken()` then `initPayWithAnyToken()` once on initial mount to prepare the deposit-and-order batch. This ensures each new buy screen starts with Predict balance selected. +- `initPayWithAnyToken()` guards against duplicate calls: if the order is already in DEPOSITING or PLACING_ORDER, it returns early. If the order is in stale SUCCESS (from a background completion), it resets via `onPlaceOrderSuccess()` first. - Transaction status events (`TransactionController:transactionStatusUpdated`) for `predictDepositAndOrder` are handled by `handleTransactionSideEffects()` in the controller, which chains deposit confirmation into `placeOrder()` automatically using the preview stored in `pendingOrderPreviews`. - When `placeOrder()` is called while the active order state is `PAY_WITH_ANY_TOKEN`, it transitions to `DEPOSITING`, stores the preview in `pendingOrderPreviews[transactionId]`, and returns early. The actual order placement happens when the deposit transaction confirms. -- On successful order placement, foreground flows invalidate order-related queries in Predict hooks, while background flows publish `PredictController:transactionStatusChanged` events that trigger app-level query invalidation and toast notifications. + +### Foreground vs Background Notification + +Order success always publishes a `PredictController:transactionStatusChanged` event (for toast + query invalidation). Order **failure** events use `transactionId` matching to distinguish foreground from background: + +- **Foreground** (user on buy screen): `params.transactionId` matches `activeBuyOrders[address].transactionId` → error shown inline on screen, no toast. +- **Background** (user navigated away): `transactionId` was cleared on navigation back via `clearActiveOrderTransactionId()`, so no match → toast fires. +- **Balance flow** (no `transactionId`): error always shown inline, no toast (balance orders complete fast). + +The `didInitiateOrderRef` in `usePredictBuyActions` tracks whether the current screen instance initiated the order. On SUCCESS, navigation only pops if the ref is true — preventing a background completion from dismissing a different buy screen. + - State transitions are gated behind the `predictWithAnyToken` feature flag — when disabled, `placeOrder()` behaves as a direct order without active order state management. ### Controller Methods (State Transitions) -| Method | Transition | Notes | -| --------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `initPayWithAnyToken()` | Sets `transactionId` on active order | Prepares deposit-and-order batch via provider; initializes to `PREVIEW` if no active order exists; guards against duplicates | -| `selectPaymentToken()` | `PREVIEW ↔ PAY_WITH_ANY_TOKEN` | Toggles between balance and external token; sets/clears `selectedPaymentToken` and clears error | -| `placeOrder()` | `PAY_WITH_ANY_TOKEN -> DEPOSITING` | When external token selected: stores preview in `pendingOrderPreviews`, transitions to `DEPOSITING`, returns early | -| `placeOrder()` | `PREVIEW -> PLACING_ORDER` | When balance selected: submits order directly to provider | -| `placeOrder()` | `PLACING_ORDER -> SUCCESS` | On successful order completion; optimistically updates balance; foreground hooks invalidate queries, and background flows publish an order confirmed event | -| `placeOrder()` | `PLACING_ORDER -> PREVIEW` | On order failure; stores error, clears payment token; if `transactionId` present, clears it and retries `initPayWithAnyToken()` | -| `onPlaceOrderEnd()` | `-> null` | Clears active order, payment token, and deposit preview | -| `clearOrderError()` | (no state change) | Removes error from active order | -| `setSelectedPaymentToken()` | (no state change) | Directly sets or clears the selected payment token in state | +| Method | Transition | Notes | +| --------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `initPayWithAnyToken()` | Creates entry or resets stale SUCCESS | Prepares deposit-and-order batch via provider; creates `{ state: PREVIEW }` entry if absent; guards against DEPOSITING/PLACING_ORDER in-flight | +| `selectPaymentToken()` | `PREVIEW ↔ PAY_WITH_ANY_TOKEN` | Toggles between balance and external token; sets/clears `selectedPaymentToken` and clears error | +| `placeOrder()` | `PAY_WITH_ANY_TOKEN -> DEPOSITING` | When external token selected: stores preview in `pendingOrderPreviews`, transitions to `DEPOSITING`, returns early | +| `placeOrder()` | `PREVIEW -> PLACING_ORDER` | When balance selected: submits order directly to provider | +| `placeOrder()` | `PLACING_ORDER -> SUCCESS` | On successful order completion; optimistically updates balance; always publishes order confirmed event | +| `placeOrder()` | `PLACING_ORDER -> PREVIEW` | On order failure; stores error, clears payment token; publishes failed event only for background orders (transactionId mismatch) | +| `onPlaceOrderSuccess()` | `-> PREVIEW` | Resets active order entry to `{ state: PREVIEW }` and clears `selectedPaymentToken` | +| `clearActiveOrderTransactionId()` | (clears transactionId only) | Called on navigation back to signal the screen is no longer handling the order; enables background toast for subsequent failures | +| `clearOrderError()` | (no state change) | Removes error from active order | +| `setSelectedPaymentToken()` | (no state change) | Directly sets or clears the selected payment token in state | ## Core Types and Utilities diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx index 70ee36fcd01..dc45415b30a 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx @@ -95,6 +95,53 @@ const createMockGameMarket = (): PredictMarket => }, }); +const createMockDrawCapableGameMarket = (): PredictMarket => { + const homeOutcome = createMockOutcome({ + id: 'outcome-home', + groupItemThreshold: 0, + tokens: [{ id: 'token-home', title: 'Home', price: 0.42 }], + }); + const drawOutcome = createMockOutcome({ + id: 'outcome-draw', + groupItemThreshold: 1, + tokens: [{ id: 'token-draw', title: 'Draw', price: 0.3 }], + }); + const awayOutcome = createMockOutcome({ + id: 'outcome-away', + groupItemThreshold: 2, + tokens: [{ id: 'token-away', title: 'Away', price: 0.28 }], + }); + + return createMockMarket({ + outcomes: [awayOutcome, drawOutcome, homeOutcome], + game: { + id: 'game-ucl-1', + startTime: '2024-12-15T13:00:00Z', + status: 'ongoing', + league: 'ucl', + elapsed: '65:00', + period: '2H', + score: { away: 1, home: 2, raw: '1-2' }, + awayTeam: { + id: 'psg', + name: 'Paris Saint-Germain', + logo: 'https://example.com/psg.png', + abbreviation: 'PSG', + color: TEST_HEX_COLORS.TEAM_SEA, + alias: 'PSG', + }, + homeTeam: { + id: 'ars', + name: 'Arsenal', + logo: 'https://example.com/ars.png', + abbreviation: 'ARS', + color: TEST_HEX_COLORS.TEAM_DEN, + alias: 'Arsenal', + }, + }, + }); +}; + const createDefaultProps = (overrides = {}) => ({ market: createMockMarket(), outcome: createMockOutcome(), @@ -130,7 +177,7 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.queryByText('YES · 65¢')).not.toBeOnTheScreen(); + expect(screen.queryByText('YES')).not.toBeOnTheScreen(); }); }); @@ -202,8 +249,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getAllByText('65¢')).toHaveLength(1); + expect(screen.getAllByText('35¢')).toHaveLength(1); }); it('calls onBetPress with yes token when yes button is pressed', () => { @@ -235,8 +284,8 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.queryByText('YES · 65¢')).not.toBeOnTheScreen(); - expect(screen.queryByText('NO · 35¢')).not.toBeOnTheScreen(); + expect(screen.queryByText('YES')).not.toBeOnTheScreen(); + expect(screen.queryByText('NO')).not.toBeOnTheScreen(); }); it('passes carousel mode to bet buttons', () => { @@ -261,8 +310,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('SEA · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('DEN · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('SEA')).toBeOnTheScreen(); + expect(screen.getByText('DEN')).toBeOnTheScreen(); + expect(screen.getAllByText('65¢')).toHaveLength(1); + expect(screen.getAllByText('35¢')).toHaveLength(1); }); it('calls onBetPress with correct token for away team', () => { @@ -294,6 +345,40 @@ describe('PredictActionButtons', () => { expect(mockOnBetPress).toHaveBeenCalledWith(outcome.tokens[1]); }); + + it('renders home, draw, and away buttons for draw-capable leagues', () => { + const market = createMockDrawCapableGameMarket(); + const props = createDefaultProps({ + market, + outcome: market.outcomes[0], + }); + + renderWithProvider(); + + expect(screen.getByText('ARS')).toBeOnTheScreen(); + expect(screen.getByText('DRAW')).toBeOnTheScreen(); + expect(screen.getByText('PSG')).toBeOnTheScreen(); + expect(screen.getAllByText('42¢')).toHaveLength(1); + expect(screen.getAllByText('30¢')).toHaveLength(1); + expect(screen.getAllByText('28¢')).toHaveLength(1); + }); + + it('calls onBetPress with draw token for draw-capable leagues', () => { + const market = createMockDrawCapableGameMarket(); + const mockOnBetPress = jest.fn(); + const props = createDefaultProps({ + market, + outcome: market.outcomes[0], + onBetPress: mockOnBetPress, + }); + + renderWithProvider(); + fireEvent.press(screen.getByTestId('action-buttons-bet-draw')); + + expect(mockOnBetPress).toHaveBeenCalledWith( + expect.objectContaining({ id: 'token-draw' }), + ); + }); }); describe('priority order', () => { @@ -321,7 +406,7 @@ describe('PredictActionButtons', () => { renderWithProvider(); expect(screen.getByText('Claim $50.25')).toBeOnTheScreen(); - expect(screen.queryByText('YES · 65¢')).not.toBeOnTheScreen(); + expect(screen.queryByText('YES')).not.toBeOnTheScreen(); }); }); @@ -361,8 +446,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getAllByText('65¢')).toHaveLength(1); + expect(screen.getAllByText('35¢')).toHaveLength(1); }); }); @@ -388,8 +475,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 73¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 29¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getAllByText('73¢')).toHaveLength(1); + expect(screen.getAllByText('29¢')).toHaveLength(1); }); it('falls back to static prices when live prices unavailable', () => { @@ -403,8 +492,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getAllByText('65¢')).toHaveLength(1); + expect(screen.getAllByText('35¢')).toHaveLength(1); }); it('uses partial live prices with fallback for missing tokens', () => { @@ -424,8 +515,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 81¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getAllByText('81¢')).toHaveLength(1); + expect(screen.getAllByText('35¢')).toHaveLength(1); }); it('subscribes with correct token IDs', () => { @@ -463,6 +556,21 @@ describe('PredictActionButtons', () => { ); }); + it('subscribes with sorted token IDs for draw-capable leagues', () => { + const market = createMockDrawCapableGameMarket(); + const props = createDefaultProps({ + market, + outcome: market.outcomes[0], + }); + + renderWithProvider(); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith( + ['token-home', 'token-draw', 'token-away'], + { enabled: true }, + ); + }); + it('displays live prices for game markets', () => { const priceMap = new Map([ [ @@ -486,8 +594,10 @@ describe('PredictActionButtons', () => { renderWithProvider(); - expect(screen.getByText('SEA · 56¢')).toBeOnTheScreen(); - expect(screen.getByText('DEN · 46¢')).toBeOnTheScreen(); + expect(screen.getByText('SEA')).toBeOnTheScreen(); + expect(screen.getByText('DEN')).toBeOnTheScreen(); + expect(screen.getAllByText('56¢')).toHaveLength(1); + expect(screen.getAllByText('46¢')).toHaveLength(1); }); }); }); diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx index 66d98ed3322..e9879846bb2 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx @@ -4,13 +4,28 @@ import PredictBetButtons from './PredictBetButtons'; import PredictClaimButton from './PredictClaimButton'; import PredictDetailsButtonsSkeleton from '../PredictDetailsButtonsSkeleton'; import { PredictActionButtonsProps } from './PredictActionButtons.types'; -import { PredictMarketStatus } from '../../types'; +import { PredictMarketStatus, PredictOutcomeToken } from '../../types'; import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; +import { isDrawCapableLeague } from '../../constants/sports'; import { BASE_PREDICT_ACTION_BUTTONS_TEST_IDS, PREDICT_ACTION_BUTTONS_TEST_IDS, } from './PredictActionButtons.testIds'; +interface ButtonConfig { + yesLabel: string; + yesPrice: number; + yesTeamColor?: string; + yesToken: PredictOutcomeToken; + noLabel: string; + noPrice: number; + noTeamColor?: string; + noToken: PredictOutcomeToken; + drawLabel?: string; + drawPrice?: number; + drawToken?: PredictOutcomeToken; +} + const PredictActionButtons: React.FC = ({ market, outcome, @@ -25,16 +40,70 @@ const PredictActionButtons: React.FC = ({ const isGameMarket = Boolean(market.game); const isMarketOpen = market.status === PredictMarketStatus.OPEN; - const tokenIds = useMemo( - () => outcome.tokens.map((token) => token.id), - [outcome.tokens], - ); + const isDrawCapable = + isGameMarket && + market.game && + isDrawCapableLeague(market.game.league) && + market.outcomes.length >= 3; + + const sortedOutcomes = useMemo(() => { + if (!isDrawCapable) { + return null; + } + return [...market.outcomes].sort( + (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), + ); + }, [isDrawCapable, market.outcomes]); + + const tokenIds = useMemo(() => { + if (sortedOutcomes) { + return sortedOutcomes + .map((marketOutcome) => marketOutcome.tokens[0]?.id) + .filter((tokenId): tokenId is string => Boolean(tokenId)); + } + + return outcome.tokens.map((token) => token.id); + }, [sortedOutcomes, outcome.tokens]); const { getPrice } = useLiveMarketPrices(tokenIds, { enabled: isMarketOpen && !isLoading, }); - const buttonConfig = useMemo(() => { + const buttonConfig = useMemo(() => { + if (sortedOutcomes && market.game) { + const homeOutcome = sortedOutcomes[0]; + const drawOutcome = sortedOutcomes[1]; + const awayOutcome = sortedOutcomes[2]; + + const homeToken = homeOutcome?.tokens[0]; + const drawToken = drawOutcome?.tokens[0]; + const awayToken = awayOutcome?.tokens[0]; + + if (!homeToken || !drawToken || !awayToken) { + return null; + } + + const { homeTeam, awayTeam } = market.game; + + const homePrice = getPrice(homeToken.id); + const drawPrice = getPrice(drawToken.id); + const awayPrice = getPrice(awayToken.id); + + return { + yesLabel: homeTeam.abbreviation, + yesPrice: Math.round((homePrice?.bestAsk ?? homeToken.price) * 100), + yesTeamColor: homeTeam.color, + yesToken: homeToken, + drawLabel: 'DRAW', + drawPrice: Math.round((drawPrice?.bestAsk ?? drawToken.price) * 100), + drawToken, + noLabel: awayTeam.abbreviation, + noPrice: Math.round((awayPrice?.bestAsk ?? awayToken.price) * 100), + noTeamColor: awayTeam.color, + noToken: awayToken, + }; + } + const tokens = outcome.tokens; if (tokens.length < 2) { return null; @@ -55,9 +124,11 @@ const PredictActionButtons: React.FC = ({ yesLabel: awayTeam.abbreviation, yesPrice: Math.round(yesPrice * 100), yesTeamColor: awayTeam.color, + yesToken, noLabel: homeTeam.abbreviation, noPrice: Math.round(noPrice * 100), noTeamColor: homeTeam.color, + noToken, }; } @@ -65,11 +136,13 @@ const PredictActionButtons: React.FC = ({ yesLabel: yesToken.title, yesPrice: Math.round(yesPrice * 100), yesTeamColor: undefined, + yesToken, noLabel: noToken.title, noPrice: Math.round(noPrice * 100), noTeamColor: undefined, + noToken, }; - }, [outcome.tokens, isGameMarket, market.game, getPrice]); + }, [outcome.tokens, isGameMarket, market.game, sortedOutcomes, getPrice]); if (isLoading) { return ( @@ -93,15 +166,20 @@ const PredictActionButtons: React.FC = ({ } if (market.status === PredictMarketStatus.OPEN && buttonConfig) { + const drawToken = buttonConfig.drawToken; + return ( onBetPress(outcome.tokens[0])} + onYesPress={() => onBetPress(buttonConfig.yesToken)} + drawLabel={buttonConfig.drawLabel} + drawPrice={buttonConfig.drawPrice} + onDrawPress={drawToken ? () => onBetPress(drawToken) : undefined} noLabel={buttonConfig.noLabel} noPrice={buttonConfig.noPrice} - onNoPress={() => onBetPress(outcome.tokens[1])} + onNoPress={() => onBetPress(buttonConfig.noToken)} yesTeamColor={buttonConfig.yesTeamColor} noTeamColor={buttonConfig.noTeamColor} testID={`${testID}${PREDICT_ACTION_BUTTONS_TEST_IDS.PREDICT_BET_BUTTON}`} diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.types.ts b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.types.ts index 91343cf77fa..7b257cd9e83 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.types.ts +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.types.ts @@ -5,7 +5,7 @@ import { } from '../../types'; import { ButtonBaseSize } from '@metamask/design-system-react-native'; -export type PredictBetButtonVariant = 'yes' | 'no'; +export type PredictBetButtonVariant = 'yes' | 'no' | 'draw'; export interface PredictBetButtonProps { label: string; @@ -22,6 +22,9 @@ export interface PredictBetButtonsProps { yesLabel: string; yesPrice: number; onYesPress: () => void; + drawLabel?: string; + drawPrice?: number; + onDrawPress?: () => void; noLabel: string; noPrice: number; onNoPress: () => void; diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx index c0b4c8dc111..18f61145d8e 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.test.tsx @@ -24,7 +24,8 @@ describe('PredictBetButton', () => { renderWithProvider(); - expect(screen.getByText('YES · 65¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('65¢')).toBeOnTheScreen(); }); it('renders with no variant label and price', () => { @@ -36,7 +37,21 @@ describe('PredictBetButton', () => { renderWithProvider(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getByText('35¢')).toBeOnTheScreen(); + }); + + it('renders with draw variant label and price', () => { + const props = createDefaultProps({ + label: 'Draw', + price: 20, + variant: 'draw', + }); + + renderWithProvider(); + + expect(screen.getByText('DRAW')).toBeOnTheScreen(); + expect(screen.getByText('20¢')).toBeOnTheScreen(); }); it('renders team abbreviation as label for game markets', () => { @@ -48,7 +63,8 @@ describe('PredictBetButton', () => { renderWithProvider(); - expect(screen.getByText('SEA · 49¢')).toBeOnTheScreen(); + expect(screen.getByText('SEA')).toBeOnTheScreen(); + expect(screen.getByText('49¢')).toBeOnTheScreen(); }); it('renders with testID', () => { @@ -138,7 +154,8 @@ describe('PredictBetButton', () => { renderWithProvider(); - expect(screen.getByText('YES · 0¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('0¢')).toBeOnTheScreen(); }); it('renders with 100 price', () => { @@ -146,7 +163,8 @@ describe('PredictBetButton', () => { renderWithProvider(); - expect(screen.getByText('YES · 100¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('100¢')).toBeOnTheScreen(); }); it('renders with empty label', () => { @@ -154,7 +172,7 @@ describe('PredictBetButton', () => { renderWithProvider(); - expect(screen.getByText(' · 65¢')).toBeOnTheScreen(); + expect(screen.getByText('65¢')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.tsx index 463a734221b..a311b2e7c12 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButton.tsx @@ -23,6 +23,9 @@ const PredictBetButton: React.FC = ({ if (hasTeamColor) { return teamColor; } + if (variant === 'draw') { + return colors.background.muted; + } return variant === 'yes' ? colors.success.muted : colors.error.muted; }; @@ -30,6 +33,9 @@ const PredictBetButton: React.FC = ({ if (hasTeamColor) { return 'text-white'; } + if (variant === 'draw') { + return 'text-default'; + } return variant === 'yes' ? 'text-success-default' : 'text-error-default'; }; @@ -42,8 +48,14 @@ const PredictBetButton: React.FC = ({ isFullWidth size={size} > - - {label.toUpperCase()} · {price}¢ + + {label.toUpperCase()} + + + {price}¢ ); diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx index f7d72266169..1ef2547c335 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.test.tsx @@ -26,8 +26,10 @@ describe('PredictBetButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('65¢')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getByText('35¢')).toBeOnTheScreen(); }); it('renders with custom labels', () => { @@ -38,8 +40,8 @@ describe('PredictBetButtons', () => { renderWithProvider(); - expect(screen.getByText('SEA · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('DEN · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('SEA')).toBeOnTheScreen(); + expect(screen.getByText('DEN')).toBeOnTheScreen(); }); it('renders with custom prices', () => { @@ -50,8 +52,8 @@ describe('PredictBetButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 49¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 51¢')).toBeOnTheScreen(); + expect(screen.getByText('49¢')).toBeOnTheScreen(); + expect(screen.getByText('51¢')).toBeOnTheScreen(); }); it('renders with testID prefix for each button', () => { @@ -62,6 +64,29 @@ describe('PredictBetButtons', () => { expect(screen.getByTestId('custom-buttons-yes')).toBeOnTheScreen(); expect(screen.getByTestId('custom-buttons-no')).toBeOnTheScreen(); }); + + it('renders draw button between yes and no when draw props are provided', () => { + const props = createDefaultProps({ + drawLabel: 'DRAW', + drawPrice: 20, + onDrawPress: jest.fn(), + }); + + renderWithProvider(); + + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('DRAW')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getByTestId('bet-buttons-draw')).toBeOnTheScreen(); + }); + + it('does not render draw button when draw props are missing', () => { + const props = createDefaultProps(); + + renderWithProvider(); + + expect(screen.queryByTestId('bet-buttons-draw')).not.toBeOnTheScreen(); + }); }); describe('press handling', () => { @@ -101,6 +126,20 @@ describe('PredictBetButtons', () => { expect(mockOnYesPress).not.toHaveBeenCalled(); expect(mockOnNoPress).not.toHaveBeenCalled(); }); + + it('calls onDrawPress when draw button is pressed', () => { + const mockOnDrawPress = jest.fn(); + const props = createDefaultProps({ + drawLabel: 'DRAW', + drawPrice: 20, + onDrawPress: mockOnDrawPress, + }); + + renderWithProvider(); + fireEvent.press(screen.getByTestId('bet-buttons-draw')); + + expect(mockOnDrawPress).toHaveBeenCalledTimes(1); + }); }); describe('team colors', () => { @@ -137,8 +176,9 @@ describe('PredictBetButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 50¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 50¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getAllByText('50¢')).toHaveLength(2); }); it('renders with extreme prices', () => { @@ -149,8 +189,10 @@ describe('PredictBetButtons', () => { renderWithProvider(); - expect(screen.getByText('YES · 99¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 1¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('99¢')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getByText('1¢')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.testIds.ts b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.testIds.ts index 82b3b7edd49..665d7771ac4 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.testIds.ts +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.testIds.ts @@ -4,5 +4,6 @@ export const BASE_PREDICT_BET_BUTTONS_TEST_IDS = { export const PREDICT_BET_BUTTONS_TEST_IDS = { PREDICT_BET_BUTTON_YES: '-yes', + PREDICT_BET_BUTTON_DRAW: '-draw', PREDICT_BET_BUTTON_NO: '-no', } as const; diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.tsx index 177963e09d4..9b0881557ef 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictBetButtons.tsx @@ -15,6 +15,9 @@ const PredictBetButtons: React.FC = ({ yesLabel, yesPrice, onYesPress, + drawLabel, + drawPrice, + onDrawPress, noLabel, noPrice, onNoPress, @@ -37,6 +40,19 @@ const PredictBetButtons: React.FC = ({ size={isCarousel ? ButtonBaseSize.Md : undefined} /> + {drawLabel !== undefined && drawPrice !== undefined && onDrawPress && ( + + + + )} { + const minSpacing = LABEL_HEIGHT + MIN_LABEL_GAP; // 48 + + describe('empty array', () => { + it('returns empty array', () => { + const result = getSeparatedLabelYPositions([]); + + expect(result).toEqual([]); + }); + }); + + describe('single position', () => { + it('returns its dotY', () => { + const input = [{ dotY: 100 }]; + + const result = getSeparatedLabelYPositions(input); + + expect(result).toEqual([100]); + }); + }); + + describe('two positions with sufficient gap', () => { + it('returns original positions unchanged', () => { + const input = [{ dotY: 50 }, { dotY: 120 }]; + + const result = getSeparatedLabelYPositions(input); + + expect(result).toEqual([50, 120]); + }); + }); + + describe('two positions with insufficient gap, first above second', () => { + it('applies symmetric centering', () => { + const input = [{ dotY: 50 }, { dotY: 80 }]; + + const result = getSeparatedLabelYPositions(input); + + const midPoint = (50 + 80) / 2; // 65 + const offset = minSpacing / 2; // 24 + expect(result).toEqual([midPoint - offset, midPoint + offset]); + expect(result).toEqual([41, 89]); + }); + }); + + describe('two positions with insufficient gap, first below second', () => { + it('applies symmetric centering reversed', () => { + const input = [{ dotY: 100 }, { dotY: 120 }]; + + const result = getSeparatedLabelYPositions(input); + + const midPoint = (100 + 120) / 2; // 110 + const offset = minSpacing / 2; // 24 + expect(result).toEqual([midPoint - offset, midPoint + offset]); + expect(result).toEqual([86, 134]); + }); + }); + + describe('three positions with no overlap but bottom overflow', () => { + it('preserves spacing and shifts group upward', () => { + const input = [{ dotY: 20 }, { dotY: 100 }, { dotY: 180 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [20, 100, 180] + // After spacing: [20, 100, 180] (no gaps < minSpacing) + // maxY = 160, overflow = 180 - 160 = 20 + // Shift all up by 20: [0, 80, 160] + expect(result).toEqual([0, 80, 160]); + }); + }); + + describe('three positions that overlap', () => { + it('pushes down correctly', () => { + const input = [{ dotY: 50 }, { dotY: 60 }, { dotY: 70 }]; + + const result = getSeparatedLabelYPositions(input); + + // First stays at 50 + // Second pushed to 50 + 48 = 98 + // Third pushed to 98 + 48 = 146 + expect(result).toEqual([50, 98, 146]); + }); + }); + + describe('three positions near bottom of chart', () => { + it('shifted upward so nothing exceeds CHART_HEIGHT - LABEL_HEIGHT', () => { + const input = [{ dotY: 140 }, { dotY: 150 }, { dotY: 160 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [140, 150, 160] + // After spacing: [140, 188, 236] + // maxY = 200 - 40 = 160 + // overflow = 236 - 160 = 76 + // Shift all up by 76: [64, 112, 160] + expect(result).toEqual([64, 112, 160]); + }); + }); + + describe('three positions where overflow shift would push top below 0', () => { + it('clamped to 0', () => { + const input = [{ dotY: 10 }, { dotY: 20 }, { dotY: 30 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [10, 20, 30] + // After spacing: [10, 58, 106] + // maxY = 160 + // overflow = 106 - 160 = -54 (no overflow) + // No shift needed + expect(result).toEqual([10, 58, 106]); + }); + }); + + describe('three positions with extreme overflow requiring clamping', () => { + it('clamps top position to 0 when shift would go negative', () => { + const input = [{ dotY: 150 }, { dotY: 160 }, { dotY: 170 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [150, 160, 170] + // After spacing: [150, 198, 246] + // maxY = 160 + // overflow = 246 - 160 = 86 + // Shift all up by 86: [64, 112, 160] + // No clamping needed since 64 > 0 + expect(result).toEqual([64, 112, 160]); + }); + }); + + describe('preserves original order in result array', () => { + it('maps positions back to original indices', () => { + const input = [{ dotY: 100 }, { dotY: 50 }, { dotY: 150 }]; + + const result = getSeparatedLabelYPositions(input); + + // Input order: [100, 50, 150] + // Sorted internally: [50, 100, 150] with indices [1, 0, 2] + // After spacing: [50, 98, 146] + // maxY = 160, overflow = 146 - 160 = -14 (no overflow) + // Result maps back to original order: result[0]=100, result[1]=50, result[2]=150 + expect(result[0]).toBe(100); // original first (100) stays 100 + expect(result[1]).toBe(50); // original second (50) stays 50 + expect(result[2]).toBe(150); // original third (150) stays 150 + }); + }); + + describe('edge case: all positions at same Y', () => { + it('spreads them with minSpacing', () => { + const input = [{ dotY: 100 }, { dotY: 100 }, { dotY: 100 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [100, 100, 100] + // After spacing: [100, 148, 196] + // maxY = 160, overflow = 196 - 160 = 36 + // Shift all up by 36: [64, 112, 160] + expect(result).toEqual([64, 112, 160]); + }); + }); + + describe('edge case: two positions at exact minSpacing boundary', () => { + it('returns original positions when gap equals minSpacing', () => { + const input = [{ dotY: 50 }, { dotY: 98 }]; + + const result = getSeparatedLabelYPositions(input); + + expect(result).toEqual([50, 98]); + }); + }); + + describe('three positions with no overflow', () => { + it('does not shift when all fit within bounds', () => { + const input = [{ dotY: 10 }, { dotY: 80 }, { dotY: 150 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [10, 80, 150] + // After spacing: [10, 80, 150] (gaps are 70 and 70, both >= 48) + // maxY = 160, overflow = 150 - 160 = -10 (no overflow) + // No shift applied + expect(result).toEqual([10, 80, 150]); + }); + }); + + describe('three positions with overflow exceeding top position', () => { + it('limits shift to top position value preserving spacing', () => { + const input = [{ dotY: 10 }, { dotY: 100 }, { dotY: 190 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [10, 100, 190] + // After spacing: [10, 100, 190] (gaps are 90 and 90, both >= 48) + // maxY = 160, overflow = 190 - 160 = 30 + // shift = Math.min(30, 10) = 10 (limited by top position) + // After shift: [0, 90, 180] — spacing preserved even though bottom overflows + expect(result).toEqual([0, 90, 180]); + expect(result[1] - result[0]).toBeGreaterThanOrEqual(minSpacing); + expect(result[2] - result[1]).toBeGreaterThanOrEqual(minSpacing); + }); + }); + + describe('four positions with mixed gaps', () => { + it('handles both sufficient and insufficient gaps', () => { + const input = [{ dotY: 20 }, { dotY: 30 }, { dotY: 100 }, { dotY: 150 }]; + + const result = getSeparatedLabelYPositions(input); + + // After sorting: [20, 30, 100, 150] + // Gap 1: 30 - 20 = 10 < 48, push to 20 + 48 = 68 + // Gap 2: 100 - 68 = 32 < 48, push to 68 + 48 = 116 + // Gap 3: 150 - 116 = 34 < 48, push to 116 + 48 = 164 + // After spacing: [20, 68, 116, 164] + // maxY = 160, overflow = 164 - 160 = 4 + // Shift all up by 4: [16, 64, 112, 160] + expect(result).toEqual([16, 64, 112, 160]); + }); + }); +}); diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts index c0252943664..0bbe19e2701 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts @@ -30,18 +30,54 @@ export const getSeparatedLabelYPositions = ( return dotPositions.map((pos) => pos.dotY); } - const [first, second] = dotPositions; - const gap = Math.abs(first.dotY - second.dotY); + const minSpacing = LABEL_HEIGHT + MIN_LABEL_GAP; - if (gap >= LABEL_HEIGHT + MIN_LABEL_GAP) { - return [first.dotY, second.dotY]; + // For 2 labels, use symmetric centering around the midpoint + if (dotPositions.length === 2) { + const [first, second] = dotPositions; + const gap = Math.abs(first.dotY - second.dotY); + + if (gap >= minSpacing) { + return [first.dotY, second.dotY]; + } + + const midPoint = (first.dotY + second.dotY) / 2; + const offset = minSpacing / 2; + + if (first.dotY < second.dotY) { + return [midPoint - offset, midPoint + offset]; + } + return [midPoint + offset, midPoint - offset]; + } + + // For 3+ labels, sort and push overlapping labels downward + const positions = dotPositions.map((pos, index) => ({ + index, + y: pos.dotY, + })); + + positions.sort((a, b) => a.y - b.y); + + for (let i = 1; i < positions.length; i++) { + const gap = positions[i].y - positions[i - 1].y; + if (gap < minSpacing) { + positions[i].y = positions[i - 1].y + minSpacing; + } } - const midPoint = (first.dotY + second.dotY) / 2; - const offset = (LABEL_HEIGHT + MIN_LABEL_GAP) / 2; + // Shift group upward if bottom label overflows chart bounds + const maxY = CHART_HEIGHT - LABEL_HEIGHT; + const overflow = positions[positions.length - 1].y - maxY; + if (overflow > 0) { + const shift = Math.min(overflow, positions[0].y); + for (const pos of positions) { + pos.y -= shift; + } + } - if (first.dotY < second.dotY) { - return [midPoint - offset, midPoint + offset]; + const result = new Array(dotPositions.length); + for (const pos of positions) { + result[pos.index] = pos.y; } - return [midPoint + offset, midPoint - offset]; + return result; }; diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx index ea4fd29a7b5..868229ca7b4 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx @@ -8,6 +8,8 @@ import React, { import { PredictGameStatus, PredictPriceHistoryInterval } from '../../types'; import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory'; import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; +import { isDrawCapableLeague } from '../../constants/sports'; +import { useTheme } from '../../../../../util/theme'; import PredictGameChartContent from './PredictGameChartContent'; import { PredictGameChartProps, @@ -66,23 +68,42 @@ const PredictGameChart: React.FC = ({ testID, }) => { const game = market.game; + const { colors } = useTheme(); const gameStatus = game?.status; const isGameEnded = gameStatus === 'ended'; const isGameOngoing = gameStatus === 'ongoing'; const tokenIds = useMemo(() => { + if ( + game?.league && + isDrawCapableLeague(game.league) && + market.outcomes.length >= 3 + ) { + return [...market.outcomes] + .sort( + (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), + ) + .map((o) => o.tokens[0]?.id) + .filter((id): id is string => Boolean(id)); + } const tokens = market.outcomes[0]?.tokens ?? []; return tokens.map((t) => t.id); - }, [market.outcomes]); + }, [market.outcomes, game?.league]); - const seriesConfig: [GameChartSeriesConfig, GameChartSeriesConfig] | null = - useMemo(() => { - if (!game) return null; + const seriesConfig: GameChartSeriesConfig[] | null = useMemo(() => { + if (!game) return null; + if (isDrawCapableLeague(game.league) && market.outcomes.length >= 3) { return [ - { label: game.awayTeam.abbreviation, color: game.awayTeam.color }, { label: game.homeTeam.abbreviation, color: game.homeTeam.color }, + { label: 'DRAW', color: colors.icon.muted }, + { label: game.awayTeam.abbreviation, color: game.awayTeam.color }, ]; - }, [game]); + } + return [ + { label: game.awayTeam.abbreviation, color: game.awayTeam.color }, + { label: game.homeTeam.abbreviation, color: game.homeTeam.color }, + ]; + }, [game, market.outcomes.length, colors.icon.muted]); const [timeframe, setTimeframe] = useState(() => getDefaultTimeframe(gameStatus), @@ -119,19 +140,20 @@ const PredictGameChart: React.FC = ({ startTs, endTs, fidelity, - enabled: tokenIds.length === 2, + enabled: tokenIds.length >= 2, }); const { prices } = useLiveMarketPrices(tokenIds, { - enabled: isLive && tokenIds.length === 2, + enabled: isLive && tokenIds.length >= 2, }); const historicalChartData: GameChartSeries[] = useMemo(() => { - if (priceHistories.length < 2 || !seriesConfig) return []; + if (priceHistories.length < tokenIds.length || !seriesConfig) return []; return tokenIds.map((_tokenId, index) => { const history = priceHistories[index] ?? []; const config = seriesConfig[index]; + if (!config) return { label: '', color: '', data: [] }; return { label: config.label, @@ -170,9 +192,8 @@ const PredictGameChart: React.FC = ({ } if ( - historicalChartData.length === 2 && - historicalChartData[0].data.length > 0 && - historicalChartData[1].data.length > 0 + historicalChartData.length >= 2 && + historicalChartData.every((s) => s.data.length > 0) ) { setLiveChartData(historicalChartData); initialDataLoadedRef.current = true; @@ -186,7 +207,7 @@ const PredictGameChart: React.FC = ({ const currentMinute = getMinuteTimestamp(now); setLiveChartData((prevData) => { - if (prevData.length !== 2) return prevData; + if (prevData.length < 2) return prevData; const lastPointSeries0 = prevData[0].data[prevData[0].data.length - 1]; if (!lastPointSeries0) return prevData; @@ -240,9 +261,7 @@ const PredictGameChart: React.FC = ({ const chartData = isLive ? liveChartData : historicalChartData; const hasChartData = - chartData.length >= 2 && - chartData[0]?.data?.length > 0 && - chartData[1]?.data?.length > 0; + chartData.length >= 2 && chartData.every((s) => s?.data?.length > 0); const isLoading = isFetching || !hasChartData || (isLive && !initialDataLoadedRef.current); diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx index 1a6c3c24b29..329a49b0b6a 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx @@ -42,7 +42,7 @@ const PredictGameChartContent: React.FC = ({ const chartWidthRef = useRef(0); const primaryDataLengthRef = useRef(0); - const seriesToRender = useMemo(() => data.slice(0, 2), [data]); + const seriesToRender = data; const nonEmptySeries = useMemo( () => seriesToRender.filter((series) => series.data.length > 0), [seriesToRender], diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index cee7882ddb4..a141b968af3 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -65,7 +65,7 @@ const PredictGameDetailsContent: React.FC = ({ return ( { renderWithProvider(); - expect(screen.getByText('YES · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('NO · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('YES')).toBeOnTheScreen(); + expect(screen.getByText('65¢')).toBeOnTheScreen(); + expect(screen.getByText('NO')).toBeOnTheScreen(); + expect(screen.getByText('35¢')).toBeOnTheScreen(); }); it('renders team buttons for game market', () => { @@ -198,8 +200,8 @@ describe('PredictGameDetailsFooter', () => { renderWithProvider(); - expect(screen.getByText('SEA · 65¢')).toBeOnTheScreen(); - expect(screen.getByText('DEN · 35¢')).toBeOnTheScreen(); + expect(screen.getByText('SEA')).toBeOnTheScreen(); + expect(screen.getByText('DEN')).toBeOnTheScreen(); }); it('calls onBetPress when bet button is pressed', () => { diff --git a/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.tsx b/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.tsx index eb45369c298..1805cda0d82 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.tsx @@ -14,6 +14,7 @@ import { IconName, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; +import { isDrawCapableLeague } from '../../constants/sports'; import { formatVolume } from '../../utils/format'; import { PredictActionButtons } from '../PredictActionButtons'; import { PredictGameDetailsFooterProps } from './PredictGameDetailsFooter.types'; @@ -43,6 +44,10 @@ const PredictGameDetailsFooter: React.FC = ({ market.status !== 'open' || market.game?.status === 'ended'; const hasClaimableWinnings = claimableAmount > 0; const showClaimButton = hasClaimableWinnings && onClaimPress; + const labelKey = + market.game?.league && isDrawCapableLeague(market.game.league) + ? 'predict.game_details_footer.make_your_prediction' + : 'predict.game_details_footer.pick_a_winner'; if (isMarketClosed && !hasClaimableWinnings) { return null; @@ -70,7 +75,7 @@ const PredictGameDetailsFooter: React.FC = ({ color={TextColor.TextAlternative} testID={`${testID}${PREDICT_GAME_DETAILS_FOOTER_TEST_IDS.LABEL}`} > - {strings('predict.game_details_footer.pick_a_winner')} + {strings(labelKey)} { render(); - expect(screen.getByText(/Yes to win/)).toBeOnTheScreen(); + expect(screen.getByText(/on Yes/)).toBeOnTheScreen(); }); it('displays formatted initialValue', () => { @@ -289,8 +289,8 @@ describe('PredictPicksForCard', () => { render(); - expect(screen.getByText(/Yes to win/)).toBeOnTheScreen(); - expect(screen.getByText(/No to win/)).toBeOnTheScreen(); + expect(screen.getByText(/on Yes/)).toBeOnTheScreen(); + expect(screen.getByText(/on No/)).toBeOnTheScreen(); }); it('calls formatPrice for each position cashPnl', () => { @@ -389,8 +389,8 @@ describe('PredictPicksForCard', () => { />, ); - expect(screen.getByText(/Provided Yes to win/)).toBeOnTheScreen(); - expect(screen.queryByText(/Fetched to win/)).toBeNull(); + expect(screen.getByText(/on Provided Yes/)).toBeOnTheScreen(); + expect(screen.queryByText(/on Fetched/)).toBeNull(); }); it('renders provided positions correctly', () => { @@ -406,8 +406,8 @@ describe('PredictPicksForCard', () => { />, ); - expect(screen.getByText(/Team A to win/)).toBeOnTheScreen(); - expect(screen.getByText(/Team B to win/)).toBeOnTheScreen(); + expect(screen.getByText(/on Team A/)).toBeOnTheScreen(); + expect(screen.getByText(/on Team B/)).toBeOnTheScreen(); }); it('returns null when provided positions is empty', () => { @@ -445,7 +445,7 @@ describe('PredictPicksForCard', () => { render(); - expect(screen.getByText(/Maybe to win/)).toBeOnTheScreen(); + expect(screen.getByText(/on Maybe/)).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx index a93662ce37a..7e1738cb6d5 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx @@ -27,8 +27,8 @@ const PredictPicksForCardItem: React.FC = ({ testID={testID} twClassName="flex-row justify-between items-center gap-2" > - - {strings('predict.position_pick_info_to_win', { + + {strings('predict.position_pick_info', { initialValue: formatPrice(position.initialValue, { maximumDecimals: 2, }), diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx index 6760ea7daeb..4727b93f964 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx @@ -76,6 +76,13 @@ const PredictSportCardFooter: React.FC = ({ const handleBetPress = useCallback( (token: PredictOutcomeToken) => { + const matchingOutcome = + market.outcomes.find((marketOutcome) => + marketOutcome.tokens.some( + (marketToken) => marketToken.id === token.id, + ), + ) ?? market.outcomes?.[0]; + executeGuardedAction( () => { // When accessed from Carousel, we're outside the Predict navigator, @@ -87,7 +94,7 @@ const PredictSportCardFooter: React.FC = ({ navigateToBuyPreview( { market, - outcome, + outcome: matchingOutcome, outcomeToken: token, entryPoint: resolvedEntryPoint, }, @@ -105,7 +112,6 @@ const PredictSportCardFooter: React.FC = ({ resolvedEntryPoint, navigateToBuyPreview, market, - outcome, ], ); diff --git a/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.tsx b/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.tsx index 8689587aa0c..9da2661415c 100644 --- a/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.tsx +++ b/app/components/UI/Predict/components/PredictSportScoreboard/PredictSportScoreboard.tsx @@ -15,6 +15,7 @@ import { getLeagueConfig } from '../../constants/sportLeagueConfigs'; import PredictSportWinner from '../PredictSportWinner/PredictSportWinner'; import { PredictMarketGame } from '../../types'; import { useLiveGameUpdates } from '../../hooks/useLiveGameUpdates'; +import { isDrawCapableLeague } from '../../constants/sports'; import { PREDICT_SPORT_SCOREBOARD_TEST_IDS } from './PredictSportScoreboard.testIds'; export interface PredictSportScoreboardProps { @@ -109,6 +110,8 @@ const PredictSportScoreboard: React.FC = ({ [game.startTime], ); + const isHomeFirst = isDrawCapableLeague(game.league); + const period = mergedData.period; const isPreGame = mergedData.status === 'scheduled' || period === 'NS'; @@ -148,6 +151,41 @@ const PredictSportScoreboard: React.FC = ({ mergedData.homeScore !== undefined && mergedData.homeScore > mergedData.awayScore; + const leftTeam = isHomeFirst ? game.homeTeam : game.awayTeam; + const rightTeam = isHomeFirst ? game.awayTeam : game.homeTeam; + const leftScore = isHomeFirst ? mergedData.homeScore : mergedData.awayScore; + const rightScore = isHomeFirst ? mergedData.awayScore : mergedData.homeScore; + const leftWon = isHomeFirst ? homeWon : awayWon; + const rightWon = isHomeFirst ? awayWon : homeWon; + const leftHasPossession = isHomeFirst ? homeHasPossession : awayHasPossession; + const rightHasPossession = isHomeFirst + ? awayHasPossession + : homeHasPossession; + + const leftTestIds = isHomeFirst + ? { + icon: PREDICT_SPORT_SCOREBOARD_TEST_IDS.HOME_TEAM_ICON, + possession: PREDICT_SPORT_SCOREBOARD_TEST_IDS.HOME_POSSESSION, + winner: PREDICT_SPORT_SCOREBOARD_TEST_IDS.HOME_WINNER, + } + : { + icon: PREDICT_SPORT_SCOREBOARD_TEST_IDS.AWAY_TEAM_ICON, + possession: PREDICT_SPORT_SCOREBOARD_TEST_IDS.AWAY_POSSESSION, + winner: PREDICT_SPORT_SCOREBOARD_TEST_IDS.AWAY_WINNER, + }; + + const rightTestIds = isHomeFirst + ? { + icon: PREDICT_SPORT_SCOREBOARD_TEST_IDS.AWAY_TEAM_ICON, + possession: PREDICT_SPORT_SCOREBOARD_TEST_IDS.AWAY_POSSESSION, + winner: PREDICT_SPORT_SCOREBOARD_TEST_IDS.AWAY_WINNER, + } + : { + icon: PREDICT_SPORT_SCOREBOARD_TEST_IDS.HOME_TEAM_ICON, + possession: PREDICT_SPORT_SCOREBOARD_TEST_IDS.HOME_POSSESSION, + winner: PREDICT_SPORT_SCOREBOARD_TEST_IDS.HOME_WINNER, + }; + const renderCenterContent = () => { if (isPreGame) { return ( @@ -197,7 +235,7 @@ const PredictSportScoreboard: React.FC = ({ variant={TextVariant.DisplayMd} twClassName="text-default leading-none" > - {mergedData.awayScore ?? 0} + {leftScore ?? 0} @@ -214,7 +252,7 @@ const PredictSportScoreboard: React.FC = ({ variant={TextVariant.DisplayMd} twClassName="text-default leading-none" > - {mergedData.homeScore ?? 0} + {rightScore ?? 0} ); @@ -236,16 +274,15 @@ const PredictSportScoreboard: React.FC = ({ > {config.TeamIcon ? ( ) : ( )} @@ -253,17 +290,16 @@ const PredictSportScoreboard: React.FC = ({ {config.TeamIcon ? ( ) : ( )} @@ -283,22 +319,22 @@ const PredictSportScoreboard: React.FC = ({ fontWeight={FontWeight.Medium} twClassName="text-alternative text-center" > - {game.awayTeam.abbreviation.toUpperCase()} + {leftTeam.abbreviation.toUpperCase()} - {config.PossessionIcon && awayHasPossession && ( + {config.PossessionIcon && leftHasPossession && ( )} - {awayWon && ( + {leftWon && ( )} @@ -308,19 +344,19 @@ const PredictSportScoreboard: React.FC = ({ flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} > - {config.PossessionIcon && homeHasPossession && ( + {config.PossessionIcon && rightHasPossession && ( )} - {homeWon && ( + {rightWon && ( )} @@ -330,7 +366,7 @@ const PredictSportScoreboard: React.FC = ({ fontWeight={FontWeight.Medium} twClassName="text-alternative text-center" > - {game.homeTeam.abbreviation.toUpperCase()} + {rightTeam.abbreviation.toUpperCase()} diff --git a/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.test.tsx b/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.test.tsx index 1504d477b59..26fe225b3b5 100644 --- a/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.test.tsx +++ b/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.test.tsx @@ -1,12 +1,10 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import PredictSportTeamLogo from './PredictSportTeamLogo'; -import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; describe('PredictSportTeamLogo', () => { const defaultProps = { uri: 'https://example.com/logo.png', - color: TEST_HEX_COLORS.TEAM_SEA, testID: 'team-logo', }; @@ -118,57 +116,6 @@ describe('PredictSportTeamLogo', () => { }); describe('edge cases', () => { - it('renders logo with hex color including alpha channel', () => { - const colorWithAlpha = TEST_HEX_COLORS.TEAM_SEA_ALPHA; - - const { getByTestId } = render( - , - ); - - const container = getByTestId('team-logo'); - expect(container.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - backgroundColor: colorWithAlpha, - }), - ]), - ); - }); - - it('renders logo with short hex color format', () => { - const shortHexColor = TEST_HEX_COLORS.WHITE_SHORT; - - const { getByTestId } = render( - , - ); - - const container = getByTestId('team-logo'); - expect(container.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - backgroundColor: shortHexColor, - }), - ]), - ); - }); - - it('renders logo with RGB color format', () => { - const rgbColor = 'rgb(0, 34, 68)'; - - const { getByTestId } = render( - , - ); - - const container = getByTestId('team-logo'); - expect(container.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - backgroundColor: rgbColor, - }), - ]), - ); - }); - it('hides image when onError is triggered', () => { const { getByTestId, queryByTestId } = render( , diff --git a/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.tsx b/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.tsx index ca2d03b1457..a4579413531 100644 --- a/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.tsx +++ b/app/components/UI/Predict/components/PredictSportTeamLogo/PredictSportTeamLogo.tsx @@ -9,7 +9,6 @@ import { interface PredictSportTeamLogoProps { uri: string; // Remote image URL (team.logo field) - color: string; // Team primary color (hex) — used as placeholder background size?: number; // Size in pixels (default: 48, same as helmet default) flipped?: boolean; // Accepted for API compatibility but IGNORED (logos don't need mirroring) testID?: string; @@ -21,7 +20,6 @@ interface PredictSportTeamLogoProps { */ const PredictSportTeamLogo: React.FC = ({ uri, - color, size = 48, testID, }) => { @@ -36,7 +34,6 @@ const PredictSportTeamLogo: React.FC = ({ style={tw.style('overflow-hidden rounded-lg', { width: size, height: size, - backgroundColor: color, })} > {!hasError && ( diff --git a/app/components/UI/Predict/constants/sports.ts b/app/components/UI/Predict/constants/sports.ts index 54dbaec4590..b29a3e942d2 100644 --- a/app/components/UI/Predict/constants/sports.ts +++ b/app/components/UI/Predict/constants/sports.ts @@ -5,11 +5,52 @@ import { PredictSportsLeague } from '../types'; * * To add a new league: * 1. Add the league to `PredictSportsLeague` type in `../types/index.ts` - * 2. Add a slug pattern regex to `LEAGUE_SLUG_PATTERNS` in `../utils/gameParser.ts` + * 2. Add a slug config to `LEAGUE_SLUG_CONFIGS` in `../utils/gameParser.ts` * 3. Add the league to this array * 4. Add tests for the new league's slug parsing */ -export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = ['nfl', 'nba']; +export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = [ + 'nfl', + 'nba', + 'ucl', + 'fif', + 'lal', + 'uef', + 'bra2', + 'tur', + 'col1', + 'mls', + 'mex', + 'bun', + 'chi', + 'epl', + 'cze1', + 'j1100', + 'j2100', + 'fl1', + 'nor', + 'aus', + 'den', + 'sea', + 'kor', + 'ere', + 'spl', + 'bra', + 'por', + 'chi1', + 'per1', + 'lib', + 'cdr', + 'sud', + 'egy1', + 'uel', + 'rou1', + 'col', + 'bol1', + 'itc', + 'dfb', + 'cde', +]; export const filterSupportedLeagues = ( leagues: string[], @@ -17,3 +58,47 @@ export const filterSupportedLeagues = ( leagues.filter((league): league is PredictSportsLeague => SUPPORTED_SPORTS_LEAGUES.includes(league as PredictSportsLeague), ); + +const DRAW_CAPABLE_LEAGUES: ReadonlySet = new Set([ + 'ucl', + 'fif', + 'lal', + 'uef', + 'bra2', + 'tur', + 'col1', + 'mls', + 'mex', + 'bun', + 'chi', + 'epl', + 'cze1', + 'j1100', + 'j2100', + 'fl1', + 'nor', + 'aus', + 'den', + 'sea', + 'kor', + 'ere', + 'spl', + 'bra', + 'por', + 'chi1', + 'per1', + 'lib', + 'cdr', + 'sud', + 'egy1', + 'uel', + 'rou1', + 'col', + 'bol1', + 'itc', + 'dfb', + 'cde', +]); + +export const isDrawCapableLeague = (league: PredictSportsLeague): boolean => + DRAW_CAPABLE_LEAGUES.has(league); diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 5f87b48d957..8e1dccffe92 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -174,10 +174,10 @@ const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; function setActiveOrderForTest( controller: PredictController, - order: PredictControllerState['activeBuyOrder'], + order: PredictControllerState['activeBuyOrders'][string], ) { controller.updateStateForTesting((state) => { - state.activeBuyOrder = order; + state.activeBuyOrders[MOCK_ADDRESS] = order; }); } @@ -1214,7 +1214,7 @@ describe('PredictController', () => { ); }); - it('does not publish order confirmed event when there is an active buy order', async () => { + it('publishes order confirmed event when there is an active buy order', async () => { const mockResult = { success: true as const, response: { @@ -1239,8 +1239,8 @@ describe('PredictController', () => { await controller.placeOrder({ preview }); - expect(handler).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'order' }), + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: 'order', status: 'confirmed' }), ); }, { @@ -1330,9 +1330,12 @@ describe('PredictController', () => { const preview = createMockOrderPreview({ side: Side.BUY }); - await expect(controller.placeOrder({ preview })).rejects.toThrow( - 'Order placement failed', - ); + await expect( + controller.placeOrder({ + preview, + transactionId: 'tx-background', + }), + ).rejects.toThrow('Order placement failed'); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ @@ -1351,7 +1354,7 @@ describe('PredictController', () => { ); }); - it('does not publish order failed event when buy order fails and there is an active buy order', async () => { + it('publishes order failed event when buy order fails and there is an active buy order', async () => { await withController( async ({ controller, messenger }) => { mockPolymarketProvider.placeOrder.mockRejectedValue( @@ -1364,16 +1367,20 @@ describe('PredictController', () => { ); setActiveOrderForTest(controller, { state: ActiveOrderState.PLACING_ORDER, + transactionId: 'tx-active', }); const preview = createMockOrderPreview({ side: Side.BUY }); - await expect(controller.placeOrder({ preview })).rejects.toThrow( - 'Order placement failed', - ); + await expect( + controller.placeOrder({ + preview, + transactionId: 'tx-background', + }), + ).rejects.toThrow('Order placement failed'); - expect(handler).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'order' }), + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: 'order', status: 'failed' }), ); }, { @@ -3965,7 +3972,7 @@ describe('PredictController', () => { controller.clearActiveOrder(); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toBeUndefined(); }); }); @@ -4080,13 +4087,15 @@ describe('PredictController', () => { }), ); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); - expect(controller.state.activeBuyOrder?.transactionId).toBe( - 'batch-123', - ); - expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.transactionId, + ).toBe('batch-123'); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeUndefined(); }); }); @@ -4103,10 +4112,12 @@ describe('PredictController', () => { }), ); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PAY_WITH_ANY_TOKEN, ); - expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeUndefined(); expect(mockPolymarketProvider.prepareDeposit).not.toHaveBeenCalled(); expect(addTransactionBatch).not.toHaveBeenCalled(); }); @@ -4124,7 +4135,7 @@ describe('PredictController', () => { }), ); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PAY_WITH_ANY_TOKEN, ); }); @@ -4142,7 +4153,7 @@ describe('PredictController', () => { }), ); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); }); @@ -4150,7 +4161,8 @@ describe('PredictController', () => { it('still sets selectedPaymentToken when activeOrder is null', () => { withController(({ controller }) => { - expect(controller.state.activeBuyOrder).toBeNull(); + controller.clearActiveOrder(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toBeUndefined(); controller.selectPaymentToken( createAssetToken({ @@ -4179,7 +4191,9 @@ describe('PredictController', () => { controller.clearOrderError(); - expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeUndefined(); }); }); @@ -4191,13 +4205,16 @@ describe('PredictController', () => { controller.clearOrderError(); - expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeUndefined(); }); }); it('does not throw when activeOrder is null', () => { withController(({ controller }) => { - expect(controller.state.activeBuyOrder).toBeNull(); + controller.clearActiveOrder(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toBeUndefined(); expect(() => controller.clearOrderError()).not.toThrow(); }); @@ -4212,7 +4229,7 @@ describe('PredictController', () => { controller.clearOrderError(); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); }); @@ -4248,7 +4265,7 @@ describe('PredictController', () => { controller.clearActiveOrder(); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toBeUndefined(); }); }); @@ -4261,14 +4278,16 @@ describe('PredictController', () => { controller.clearOrderError(); - expect(controller.state.activeBuyOrder?.error).toBeUndefined(); - expect(controller.state.activeBuyOrder?.state).toBe( + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeUndefined(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); }); }); - it('onPlaceOrderEnd sets activeBuyOrder to null and does not clear pendingOrderPreviews', () => { + it('clearActiveOrder sets activeBuyOrder to null and does not clear pendingOrderPreviews', () => { withController(({ controller }) => { setActiveOrderForTest(controller, { state: ActiveOrderState.SUCCESS, @@ -4278,9 +4297,9 @@ describe('PredictController', () => { signerAddress: MOCK_ADDRESS, }; - controller.onPlaceOrderEnd(); + controller.clearActiveOrder(); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toBeUndefined(); expect(getPendingOrderPreviews(controller)['tx-123']).toBeDefined(); }); }); @@ -4301,7 +4320,7 @@ describe('PredictController', () => { transactionId: 'tx-100', }); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.DEPOSITING, ); expect( @@ -4334,13 +4353,13 @@ describe('PredictController', () => { symbol: 'USDC.e', } as any); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PAY_WITH_ANY_TOKEN, ); }); }); - it('isCurrentActiveBuyOrder returns false when activeBuyOrder has no transactionId and a transactionId is provided', async () => { + it('updates activeBuyOrder to SUCCESS when buy order completes regardless of transactionId mismatch', async () => { await withController( async ({ controller }) => { setActiveOrderForTest(controller, { @@ -4363,8 +4382,8 @@ describe('PredictController', () => { transactionId: 'tx-1', }); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PREVIEW, + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.SUCCESS, ); }, { @@ -4379,8 +4398,11 @@ describe('PredictController', () => { }); describe('payWithAnyTokenConfirmation', () => { - it('initializes an order when there is no active order', async () => { + it('initializes deposit batch with default active order in PREVIEW state', async () => { await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); controller.setSelectedPaymentToken({ address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', chainId: '0x89', @@ -4395,10 +4417,9 @@ describe('PredictController', () => { batchId: 'default-batch', }, }); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); - expect(controller.state.selectedPaymentToken).toBeNull(); expect(mockPolymarketProvider.prepareDeposit).toHaveBeenCalled(); expect(addTransactionBatch).toHaveBeenCalled(); }); @@ -4414,7 +4435,7 @@ describe('PredictController', () => { const result = await controller.initPayWithAnyToken(); expect(result.success).toBe(true); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PAY_WITH_ANY_TOKEN, ); }); @@ -4511,7 +4532,7 @@ describe('PredictController', () => { it('clears error on activeBuyOrder after successful batch creation', async () => { await withController(async ({ controller }) => { controller.updateStateForTesting((state) => { - state.activeBuyOrder = { + state.activeBuyOrders[MOCK_ADDRESS] = { state: ActiveOrderState.PREVIEW, error: 'previous-error', }; @@ -4525,7 +4546,9 @@ describe('PredictController', () => { batchId: 'default-batch', }, }); - expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeUndefined(); }); }); }); @@ -4985,7 +5008,7 @@ describe('PredictController', () => { }); }); - it('clears preview activeOrder when deposit-and-order transaction is rejected after switching back to balance', () => { + it('resets activeOrder to PREVIEW when deposit-and-order transaction is rejected after switching back to balance', () => { withController(({ controller, messenger }) => { const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictDeposit, @@ -5008,11 +5031,16 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.transactionId, + ).toBeUndefined(); }); }); - it('clears activeOrder when deposit-and-order transaction is rejected from preview while an external token is still selected', () => { + it('resets activeOrder to PREVIEW and clears selectedPaymentToken when deposit-and-order transaction is rejected from preview while an external token is still selected', () => { withController(({ controller, messenger }) => { const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictDeposit, @@ -5040,12 +5068,17 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.transactionId, + ).toBeUndefined(); expect(controller.state.selectedPaymentToken).toBeNull(); }); }); - it('clears activeOrder when deposit-and-order transaction is rejected outside preview', () => { + it('resets activeOrder to PREVIEW when deposit-and-order transaction is rejected outside preview', () => { withController(({ controller, messenger }) => { const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictDeposit, @@ -5068,7 +5101,12 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.transactionId, + ).toBeUndefined(); }); }); @@ -7445,8 +7483,8 @@ describe('PredictController', () => { }); }); - describe('onPlaceOrderEnd', () => { - it('clears activeOrder and selectedPaymentToken', () => { + describe('onPlaceOrderSuccess', () => { + it('resets activeBuyOrder to PREVIEW and clears selectedPaymentToken', () => { withController(({ controller }) => { setActiveOrderForTest(controller, { state: ActiveOrderState.SUCCESS, @@ -7457,14 +7495,16 @@ describe('PredictController', () => { symbol: 'USDC', }); - controller.onPlaceOrderEnd(); + controller.onPlaceOrderSuccess(); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toEqual({ + state: ActiveOrderState.PREVIEW, + }); expect(controller.state.selectedPaymentToken).toBeNull(); }); }); - it('does not clear pendingOrderPreviews on navigation away', () => { + it('does not clear pendingOrderPreviews on success reset', () => { withController(({ controller }) => { ( controller as unknown as { @@ -7480,7 +7520,7 @@ describe('PredictController', () => { signerAddress: MOCK_ADDRESS, }; - controller.onPlaceOrderEnd(); + controller.onPlaceOrderSuccess(); expect( ( @@ -7518,7 +7558,7 @@ describe('PredictController', () => { success: false, response: { status: 'deposit_in_progress' }, }); - expect(controller.state.activeBuyOrder).toEqual({ + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toEqual({ state: ActiveOrderState.DEPOSITING, transactionId: 'tx-deposit-1', }); @@ -7554,7 +7594,7 @@ describe('PredictController', () => { await controller.placeOrder({ preview }); - expect(controller.state.activeBuyOrder).toEqual({ + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toEqual({ state: ActiveOrderState.SUCCESS, }); }, @@ -7597,12 +7637,12 @@ describe('PredictController', () => { expect(retrySpy).toHaveBeenCalledTimes(1); expect( - controller.state.activeBuyOrder?.transactionId, + controller.state.activeBuyOrders[MOCK_ADDRESS]?.transactionId, ).toBeUndefined(); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); - expect(controller.state.activeBuyOrder?.error).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.error).toBe( 'Order placement failed', ); }, @@ -7621,8 +7661,10 @@ describe('PredictController', () => { await withController( async ({ controller }) => { - setActiveOrderForTest(controller, { - state: ActiveOrderState.PREVIEW, + controller.updateStateForTesting((state) => { + state.activeBuyOrders[explicitAddress] = { + state: ActiveOrderState.PREVIEW, + }; }); mockPolymarketProvider.placeOrder.mockResolvedValue({ @@ -7638,7 +7680,7 @@ describe('PredictController', () => { await controller.placeOrder({ preview, address: explicitAddress }); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[explicitAddress]?.state).toBe( ActiveOrderState.SUCCESS, ); expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( @@ -7696,11 +7738,14 @@ describe('PredictController', () => { ); }); - it('does not update activeBuyOrder when background order completes for a different active order', async () => { + it('updates activeBuyOrder to SUCCESS when background order completes for any buy order', async () => { + const bgAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; await withController( async ({ controller }) => { - setActiveOrderForTest(controller, { - state: ActiveOrderState.PREVIEW, + controller.updateStateForTesting((state) => { + state.activeBuyOrders[bgAddress] = { + state: ActiveOrderState.PREVIEW, + }; }); ( @@ -7715,7 +7760,7 @@ describe('PredictController', () => { } ).pendingOrderPreviews['tx-bg-1'] = { preview: createMockOrderPreview({ side: Side.BUY }), - signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + signerAddress: bgAddress, }; mockPolymarketProvider.placeOrder.mockResolvedValue({ @@ -7731,12 +7776,12 @@ describe('PredictController', () => { await controller.placeOrder({ preview, - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + address: bgAddress, transactionId: 'tx-bg-1', }); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PREVIEW, + expect(controller.state.activeBuyOrders[bgAddress]?.state).toBe( + ActiveOrderState.SUCCESS, ); expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); expect( @@ -7762,7 +7807,7 @@ describe('PredictController', () => { ); }); - it('does not clear selectedPaymentToken when background order fails for a different active order', async () => { + it('clears selectedPaymentToken when buy order fails with flag enabled', async () => { await withController( async ({ controller }) => { setActiveOrderForTest(controller, { @@ -7785,11 +7830,7 @@ describe('PredictController', () => { controller.placeOrder({ preview, transactionId: 'tx-bg-1' }), ).rejects.toThrow('Order failed'); - expect(controller.state.selectedPaymentToken).toEqual({ - address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - chainId: '0x89', - symbol: 'USDC', - }); + expect(controller.state.selectedPaymentToken).toBeNull(); }, { mocks: { @@ -7906,11 +7947,14 @@ describe('PredictController', () => { ); }); - it('does not enter PAY_WITH_ANY_TOKEN branch when transactionId has existing pending preview', async () => { + it('skips PAY_WITH_ANY_TOKEN deposit branch and places order directly when transactionId has existing pending preview', async () => { + const bgAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; await withController( async ({ controller }) => { - setActiveOrderForTest(controller, { - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + controller.updateStateForTesting((state) => { + state.activeBuyOrders[bgAddress] = { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }; }); ( @@ -7925,7 +7969,7 @@ describe('PredictController', () => { } ).pendingOrderPreviews['tx-bg-1'] = { preview: createMockOrderPreview({ side: Side.BUY }), - signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + signerAddress: bgAddress, }; mockPolymarketProvider.placeOrder.mockResolvedValue({ @@ -7941,12 +7985,12 @@ describe('PredictController', () => { await controller.placeOrder({ preview, - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + address: bgAddress, transactionId: 'tx-bg-1', }); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PAY_WITH_ANY_TOKEN, + expect(controller.state.activeBuyOrders[bgAddress]?.state).toBe( + ActiveOrderState.SUCCESS, ); expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); }, @@ -7987,7 +8031,7 @@ describe('PredictController', () => { expect(result.success).toBe(true); expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); - expect(controller.state.activeBuyOrder?.state).not.toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).not.toBe( ActiveOrderState.DEPOSITING, ); }); @@ -8010,7 +8054,7 @@ describe('PredictController', () => { await controller.placeOrder({ preview }); - expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]).toBeUndefined(); }); }); @@ -8034,7 +8078,7 @@ describe('PredictController', () => { await controller.placeOrder({ preview }); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); }); @@ -8055,7 +8099,7 @@ describe('PredictController', () => { 'Order failed', ); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PLACING_ORDER, ); expect(controller.state.lastError).toBe('Order failed'); @@ -8087,7 +8131,9 @@ describe('PredictController', () => { ); expect(retrySpy).not.toHaveBeenCalled(); - expect(controller.state.activeBuyOrder?.transactionId).toBe('batch-1'); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.transactionId, + ).toBe('batch-1'); }); }); }); @@ -8207,7 +8253,7 @@ describe('PredictController', () => { }); }); - it('returns activeBuyOrder to preview and retries when depositAndOrder transaction fails', () => { + it('returns activeBuyOrder to PAY_WITH_ANY_TOKEN and retries when depositAndOrder transaction fails', () => { withController(({ controller, messenger }) => { setActiveOrderForTest(controller, { state: ActiveOrderState.DEPOSITING, @@ -8234,8 +8280,8 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PREVIEW, + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, ); expect(retrySpy).toHaveBeenCalledTimes(1); }); @@ -8277,10 +8323,10 @@ describe('PredictController', () => { } as { transactionMeta: TransactionMeta }); expect(retrySpy).toHaveBeenCalledTimes(1); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PREVIEW, + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, ); - expect(controller.state.activeBuyOrder?.error).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.error).toBe( 'User rejected the request.', ); }); @@ -8312,7 +8358,9 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(controller.state.activeBuyOrder?.error).toBeDefined(); + expect( + controller.state.activeBuyOrders[MOCK_ADDRESS]?.error, + ).toBeDefined(); expect(retrySpy).toHaveBeenCalledTimes(1); }); }); @@ -8350,11 +8398,11 @@ describe('PredictController', () => { }); }); - it('does not publish order failed event when depositAndOrder fails and there is an active buy order', () => { + it('publishes order failed event when depositAndOrder fails and there is an active buy order', () => { withController(({ controller, messenger }) => { setActiveOrderForTest(controller, { state: ActiveOrderState.DEPOSITING, - transactionId: 'tx-1', + transactionId: 'tx-active', }); jest @@ -8383,8 +8431,8 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(handler).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'order' }), + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: 'order', status: 'failed' }), ); }); }); @@ -8444,13 +8492,13 @@ describe('PredictController', () => { address: accountAddress, transactionId: 'tx-1', }); - expect(controller.state.activeBuyOrder?.state).toBe( + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( ActiveOrderState.PREVIEW, ); }); }); - it('does not retry initPayWithAnyToken when deposit fails for a different active order', () => { + it('retries initPayWithAnyToken when deposit fails and activeBuyOrder exists', () => { withController(({ controller, messenger }) => { setActiveOrderForTest(controller, { state: ActiveOrderState.PREVIEW, @@ -8476,9 +8524,9 @@ describe('PredictController', () => { }, } as { transactionMeta: TransactionMeta }); - expect(retrySpy).not.toHaveBeenCalled(); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PREVIEW, + expect(retrySpy).toHaveBeenCalledTimes(1); + expect(controller.state.activeBuyOrders[MOCK_ADDRESS]?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, ); }); }); @@ -8564,7 +8612,7 @@ describe('PredictController', () => { } as any); controller.updateStateForTesting((state) => { - state.activeBuyOrder = { + state.activeBuyOrders[MOCK_ADDRESS] = { state: ActiveOrderState.DEPOSITING, transactionId: 'tx-switched', }; @@ -8610,7 +8658,7 @@ describe('PredictController', () => { it('forwards the transaction address to initPayWithAnyToken when depositAndOrder fails after account switch', () => { withController(({ controller, messenger }) => { controller.updateStateForTesting((state) => { - state.activeBuyOrder = { + state.activeBuyOrders[originalAddress] = { state: ActiveOrderState.DEPOSITING, transactionId: 'tx-switched', }; @@ -8635,8 +8683,8 @@ describe('PredictController', () => { } as { transactionMeta: TransactionMeta }); expect(retrySpy).toHaveBeenCalled(); - expect(controller.state.activeBuyOrder?.state).toBe( - ActiveOrderState.PREVIEW, + expect(controller.state.activeBuyOrders[originalAddress]?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, ); }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index d13d6a0dbc1..bd7ebdd499e 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -151,11 +151,13 @@ export type PredictControllerState = { // TODO: change to be per-account basis withdrawTransaction: PredictWithdraw | null; - activeBuyOrder: { - transactionId?: string; - state: ActiveOrderState; - error?: string; - } | null; + activeBuyOrders: { + [address: string]: { + transactionId?: string; + state: ActiveOrderState; + error?: string; + }; + }; selectedPaymentToken: { address: string; @@ -181,7 +183,7 @@ export const getDefaultPredictControllerState = (): PredictControllerState => ({ pendingDeposits: {}, pendingClaims: {}, withdrawTransaction: null, - activeBuyOrder: null, + activeBuyOrders: {}, selectedPaymentToken: null, accountMeta: {}, }); @@ -244,7 +246,7 @@ const metadata: StateMetadata = { includeInStateLogs: false, usedInUi: true, }, - activeBuyOrder: { + activeBuyOrders: { persist: false, includeInDebugSnapshot: false, includeInStateLogs: false, @@ -526,13 +528,6 @@ export class PredictController extends BaseController< }; } - private isCurrentActiveBuyOrder(transactionId?: string): boolean { - if (!this.state.activeBuyOrder) return false; - if (!transactionId) return true; - if (!this.state.activeBuyOrder.transactionId) return false; - return this.state.activeBuyOrder.transactionId === transactionId; - } - private getEvmAccountAddress(): string { const accounts = this.messenger.call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', @@ -1477,9 +1472,8 @@ export class PredictController extends BaseController< async placeOrder(params: PlaceOrderParams): Promise { const activeOrderAddress = params.address ?? this.getEvmAccountAddress(); const { predictWithAnyTokenEnabled } = this.resolveFeatureFlags(); - const canUpdateActiveBuyOrder = this.isCurrentActiveBuyOrder( - params.transactionId, - ); + const isBuyWithAnyToken = + predictWithAnyTokenEnabled && params.preview.side === Side.BUY; const isExistingPendingOrder = !!params.transactionId && @@ -1487,7 +1481,7 @@ export class PredictController extends BaseController< if ( predictWithAnyTokenEnabled && - this.state.activeBuyOrder?.state === + this.state.activeBuyOrders[activeOrderAddress]?.state === ActiveOrderState.PAY_WITH_ANY_TOKEN && !isExistingPendingOrder ) { @@ -1500,9 +1494,11 @@ export class PredictController extends BaseController< }; } this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.state = ActiveOrderState.DEPOSITING; - state.activeBuyOrder.transactionId = transactionId; + if (state.activeBuyOrders[activeOrderAddress]) { + state.activeBuyOrders[activeOrderAddress].state = + ActiveOrderState.DEPOSITING; + state.activeBuyOrders[activeOrderAddress].transactionId = + transactionId; } }); return { @@ -1511,14 +1507,11 @@ export class PredictController extends BaseController< } as unknown as Result; } - if ( - predictWithAnyTokenEnabled && - params.preview.side === Side.BUY && - canUpdateActiveBuyOrder - ) { + if (isBuyWithAnyToken) { this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.state = ActiveOrderState.PLACING_ORDER; + if (state.activeBuyOrders[activeOrderAddress]) { + state.activeBuyOrders[activeOrderAddress].state = + ActiveOrderState.PLACING_ORDER; } }); } @@ -1559,9 +1552,6 @@ export class PredictController extends BaseController< const signer = this.getSigner(activeOrderAddress); - //await new Promise((resolve) => setTimeout(resolve, 1000)); - //throw new Error('Test error'); - // Track Predict Trade Transaction with submitted status (fire and forget) this.trackPredictOrderEvent({ status: PredictTradeStatus.SUBMITTED, @@ -1586,14 +1576,11 @@ export class PredictController extends BaseController< throw new Error(result.error); } - if ( - predictWithAnyTokenEnabled && - preview.side === Side.BUY && - canUpdateActiveBuyOrder - ) { + if (isBuyWithAnyToken) { this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.state = ActiveOrderState.SUCCESS; + if (state.activeBuyOrders[activeOrderAddress]) { + state.activeBuyOrders[activeOrderAddress].state = + ActiveOrderState.SUCCESS; } }); } @@ -1634,11 +1621,7 @@ export class PredictController extends BaseController< // If we can't get real share price, continue without it } - if ( - predictWithAnyTokenEnabled && - preview.side === Side.BUY && - !canUpdateActiveBuyOrder - ) { + if (isBuyWithAnyToken) { this.messenger.publish('PredictController:transactionStatusChanged', { type: 'order', status: 'confirmed', @@ -1681,23 +1664,24 @@ export class PredictController extends BaseController< this.update((state) => { state.lastError = errorMessage; state.lastUpdateTimestamp = Date.now(); - if ( - predictWithAnyTokenEnabled && - preview.side === Side.BUY && - canUpdateActiveBuyOrder && - state.activeBuyOrder - ) { - state.activeBuyOrder.state = ActiveOrderState.PREVIEW; - state.activeBuyOrder.error = errorMessage; + if (isBuyWithAnyToken && state.activeBuyOrders[activeOrderAddress]) { + state.activeBuyOrders[activeOrderAddress].state = + ActiveOrderState.PREVIEW; + state.activeBuyOrders[activeOrderAddress].error = errorMessage; } - if (canUpdateActiveBuyOrder) { + if (isBuyWithAnyToken) { state.selectedPaymentToken = null; } }); traceData = { success: false, error: errorMessage }; - if (!canUpdateActiveBuyOrder) { + const isBackgroundOrder = + params.transactionId !== undefined && + params.transactionId !== + this.state.activeBuyOrders[activeOrderAddress]?.transactionId; + + if (isBuyWithAnyToken && isBackgroundOrder) { this.messenger.publish('PredictController:transactionStatusChanged', { type: 'order', status: 'failed', @@ -1720,13 +1704,12 @@ export class PredictController extends BaseController< ); if ( - predictWithAnyTokenEnabled && - canUpdateActiveBuyOrder && - this.state.activeBuyOrder?.transactionId + isBuyWithAnyToken && + this.state.activeBuyOrders[activeOrderAddress]?.transactionId ) { this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.transactionId = undefined; + if (state.activeBuyOrders[activeOrderAddress]) { + state.activeBuyOrders[activeOrderAddress].transactionId = undefined; } }); this.initPayWithAnyToken().catch((err) => { @@ -2082,20 +2065,33 @@ export class PredictController extends BaseController< } public clearOrderError(): void { + const address = this.getEvmAccountAddress(); this.update((state) => { - if (state.activeBuyOrder) { - delete state.activeBuyOrder.error; + if (state.activeBuyOrders[address]) { + delete state.activeBuyOrders[address].error; } }); } - public onPlaceOrderEnd(): void { + public onPlaceOrderSuccess(): void { + const address = this.getEvmAccountAddress(); this.update((state) => { - state.activeBuyOrder = null; + state.activeBuyOrders[address] = { + state: ActiveOrderState.PREVIEW, + }; }); this.setSelectedPaymentToken(null); } + public clearActiveOrderTransactionId(): void { + const address = this.getEvmAccountAddress(); + this.update((state) => { + if (state.activeBuyOrders[address]?.transactionId) { + state.activeBuyOrders[address].transactionId = undefined; + } + }); + } + public selectPaymentToken(token: AssetType | null): void { if (!token) { return; @@ -2114,7 +2110,8 @@ export class PredictController extends BaseController< }, ); - const activeOrder = this.state.activeBuyOrder; + const address = this.getEvmAccountAddress(); + const activeOrder = this.state.activeBuyOrders[address]; if (!activeOrder) { return; } @@ -2126,8 +2123,8 @@ export class PredictController extends BaseController< return; } this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + if (state.activeBuyOrders[address]) { + state.activeBuyOrders[address].state = ActiveOrderState.PREVIEW; } }); return; @@ -2138,16 +2135,18 @@ export class PredictController extends BaseController< return; } this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.state = ActiveOrderState.PAY_WITH_ANY_TOKEN; + if (state.activeBuyOrders[address]) { + state.activeBuyOrders[address].state = + ActiveOrderState.PAY_WITH_ANY_TOKEN; } }); } } public clearActiveOrder(): void { + const address = this.getEvmAccountAddress(); this.update((state) => { - state.activeBuyOrder = null; + delete state.activeBuyOrders[address]; }); } @@ -2292,21 +2291,19 @@ export class PredictController extends BaseController< */ public async initPayWithAnyToken(): Promise> { const provider = this.provider; + const address = this.getEvmAccountAddress(); - if (!this.state.activeBuyOrder) { + if (!this.state.activeBuyOrders[address]) { this.update((state) => { - state.selectedPaymentToken = null; - state.activeBuyOrder = { - state: ActiveOrderState.PREVIEW, - }; + state.activeBuyOrders[address] = { state: ActiveOrderState.PREVIEW }; }); } - const activeOrder = this.state.activeBuyOrder; - if (!activeOrder) { - throw new Error( - 'Active order is required for pay-with-any-token confirmation', - ); + const currentState = this.state.activeBuyOrders[address]?.state; + + // Reset stale SUCCESS from a background-completed order + if (currentState === ActiveOrderState.SUCCESS) { + this.onPlaceOrderSuccess(); } try { @@ -2383,8 +2380,8 @@ export class PredictController extends BaseController< const { batchId } = batchResult; this.update((state) => { - if (state.activeBuyOrder) { - delete state.activeBuyOrder.error; + if (state.activeBuyOrders[address]) { + delete state.activeBuyOrders[address].error; } }); @@ -2568,21 +2565,24 @@ export class PredictController extends BaseController< : null; const marketId = pendingOrder?.analyticsProperties?.marketId; + const isBackgroundOrder = + transactionId !== undefined && + transactionId !== this.state.activeBuyOrders[address]?.transactionId; + if (transactionId) { delete this.pendingOrderPreviews[transactionId]; } - const canUpdateActiveBuyOrder = - this.isCurrentActiveBuyOrder(transactionId); - if (canUpdateActiveBuyOrder) { + if (this.state.activeBuyOrders[address]) { const errorMessage = transactionMeta.error?.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED; this.update((state) => { - if (state.activeBuyOrder) { - state.activeBuyOrder.state = ActiveOrderState.PREVIEW; - state.activeBuyOrder.error = errorMessage; - state.activeBuyOrder.transactionId = undefined; + if (state.activeBuyOrders[address]) { + state.activeBuyOrders[address].state = + ActiveOrderState.PAY_WITH_ANY_TOKEN; + state.activeBuyOrders[address].error = errorMessage; + state.activeBuyOrders[address].transactionId = undefined; } }); this.initPayWithAnyToken().catch((error) => { @@ -2593,7 +2593,9 @@ export class PredictController extends BaseController< }), ); }); - } else { + } + + if (isBackgroundOrder) { this.messenger.publish('PredictController:transactionStatusChanged', { type: 'order', status: 'failed', @@ -2609,8 +2611,14 @@ export class PredictController extends BaseController< delete this.pendingOrderPreviews[transactionId]; } - if (this.isCurrentActiveBuyOrder(transactionId)) { - this.onPlaceOrderEnd(); + if (this.state.activeBuyOrders[address]) { + this.update((state) => { + if (state.activeBuyOrders[address]) { + state.activeBuyOrders[address].state = ActiveOrderState.PREVIEW; + state.activeBuyOrders[address].transactionId = undefined; + } + }); + this.setSelectedPaymentToken(null); } } diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts index 30df5665e9b..7e6443246a8 100644 --- a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts +++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts @@ -20,22 +20,49 @@ jest.mock('react-redux', () => ({ const mockUseSelector = useSelector as jest.MockedFunction; +const MOCK_ADDRESS = '0xabc123'; + +const mockAccountsController = { + internalAccounts: { + selectedAccount: 'acct-1', + accounts: { + 'acct-1': { + address: MOCK_ADDRESS, + type: 'eip155:eoa', + id: 'acct-1', + metadata: { + name: 'Account 1', + keyring: { type: 'HD Key Tree' }, + importTime: 0, + lastSelected: 0, + }, + options: {}, + methods: [], + scopes: [], + }, + }, + }, +}; + +const createMockState = (activeBuyOrder: Record | null) => ({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrders: activeBuyOrder + ? { [MOCK_ADDRESS]: activeBuyOrder } + : {}, + }, + AccountsController: mockAccountsController, + }, + }, +}); + describe('usePredictActiveOrder', () => { beforeEach(() => { jest.clearAllMocks(); mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { - return selector({ - engine: { - backgroundState: { - PredictController: { - activeBuyOrder: { - state: ActiveOrderState.PREVIEW, - }, - }, - }, - }, - }); + return selector(createMockState({ state: ActiveOrderState.PREVIEW })); } return undefined; @@ -63,17 +90,8 @@ describe('usePredictActiveOrder', () => { }; mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { - return selector({ - engine: { - backgroundState: { - PredictController: { - activeBuyOrder: mockActiveOrder, - }, - }, - }, - }); + return selector(createMockState(mockActiveOrder)); } - return undefined; }); @@ -85,19 +103,10 @@ describe('usePredictActiveOrder', () => { it('returns isDepositing when active order is depositing', () => { mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { - return selector({ - engine: { - backgroundState: { - PredictController: { - activeBuyOrder: { - state: ActiveOrderState.DEPOSITING, - }, - }, - }, - }, - }); + return selector( + createMockState({ state: ActiveOrderState.DEPOSITING }), + ); } - return undefined; }); @@ -110,19 +119,10 @@ describe('usePredictActiveOrder', () => { it('returns isPlacingOrder when active order is placing order', () => { mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { - return selector({ - engine: { - backgroundState: { - PredictController: { - activeBuyOrder: { - state: ActiveOrderState.PLACING_ORDER, - }, - }, - }, - }, - }); + return selector( + createMockState({ state: ActiveOrderState.PLACING_ORDER }), + ); } - return undefined; }); @@ -135,17 +135,8 @@ describe('usePredictActiveOrder', () => { it('returns false flags when there is no active buy order', () => { mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { - return selector({ - engine: { - backgroundState: { - PredictController: { - activeBuyOrder: null, - }, - }, - }, - }); + return selector(createMockState(null)); } - return undefined; }); diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts index 9ddac26d0b5..36973614888 100644 --- a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts @@ -20,6 +20,10 @@ export const usePredictActiveOrder = () => { PredictController.clearOrderError(); }, [PredictController]); + const clearActiveOrderTransactionId = useCallback(() => { + PredictController.clearActiveOrderTransactionId(); + }, [PredictController]); + const currentState = useMemo(() => activeOrder?.state, [activeOrder]); const isDepositing = useMemo( @@ -37,5 +41,6 @@ export const usePredictActiveOrder = () => { isDepositing, isPlacingOrder, clearOrderError, + clearActiveOrderTransactionId, }; }; diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts index 7489f49772b..9b70bcc55ae 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts @@ -418,6 +418,139 @@ describe('usePredictOrderPreview', () => { }); }); + describe('sticky error behavior', () => { + it('preserves error during background auto-refresh', async () => { + const { Wrapper } = createWrapper(); + mockPreviewOrder.mockRejectedValue(new Error('Not enough shares')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const params = { ...defaultParams, autoRefreshTimeout: 1000 }; + const { result } = renderHook(() => usePredictOrderPreview(params), { + wrapper: Wrapper, + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to preview order'); + }); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.error).toBe('Failed to preview order'); + + consoleErrorSpy.mockRestore(); + }); + + it('clears error when auto-refresh succeeds after previous failure', async () => { + const { Wrapper } = createWrapper(); + mockPreviewOrder.mockRejectedValue(new Error('Not enough shares')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const params = { ...defaultParams, autoRefreshTimeout: 1000 }; + const { result } = renderHook(() => usePredictOrderPreview(params), { + wrapper: Wrapper, + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to preview order'); + }); + + mockPreviewOrder.mockResolvedValue(mockPreview); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + + expect(result.current.preview).toEqual(mockPreview); + + consoleErrorSpy.mockRestore(); + }); + + it('clears error when size changes', async () => { + const { Wrapper } = createWrapper(); + mockPreviewOrder.mockRejectedValue(new Error('Not enough shares')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const { result, rerender } = renderHook( + (props: PreviewOrderParams) => usePredictOrderPreview(props), + { wrapper: Wrapper, initialProps: defaultParams }, + ); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to preview order'); + }); + + mockPreviewOrder.mockResolvedValue(mockPreview); + rerender({ ...defaultParams, size: 200 }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('returns false for isLoading when sticky error exists during refetch', async () => { + const { Wrapper } = createWrapper(); + mockPreviewOrder.mockRejectedValue(new Error('Not enough shares')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const params = { ...defaultParams, autoRefreshTimeout: 1000 }; + const { result } = renderHook(() => usePredictOrderPreview(params), { + wrapper: Wrapper, + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + }); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.isLoading).toBe(false); + + consoleErrorSpy.mockRestore(); + }); + }); + describe('parameter changes', () => { it('reacts to outcomeTokenId changes', async () => { const { Wrapper } = createWrapper(); diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts index 80d8c6605ea..db18501aea4 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts @@ -67,16 +67,30 @@ export function usePredictOrderPreview( hasValidSize && autoRefreshTimeout ? autoRefreshTimeout : false, }); + const [stickyError, setStickyError] = useState(null); + const preview = hasValidSize ? (query.data ?? null) : null; - const error = query.error + const parsedError = query.error ? parseErrorMessage({ error: query.error, defaultCode: PREDICT_ERROR_CODES.PREVIEW_FAILED, }) : null; - const isLoading = preview === null && !error; + const isLoading = preview === null && !parsedError && !stickyError; const isCalculating = query.isFetching; + useEffect(() => { + if (parsedError) { + setStickyError(parsedError); + } else if (query.isSuccess) { + setStickyError(null); + } + }, [parsedError, query.isSuccess]); + + useEffect(() => { + setStickyError(null); + }, [debouncedParams.size]); + useEffect(() => { if (!query.error) return; @@ -108,6 +122,6 @@ export function usePredictOrderPreview( preview, isLoading, isCalculating, - error, + error: stickyError, }; } diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx index aefb02f599b..ad2acf2f3de 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx @@ -796,39 +796,6 @@ describe('usePredictToastRegistrations', () => { ); }); - it('shows prediction placed toast with View button when marketId is present', () => { - const handler = getHandler(); - - handler( - { - type: 'order', - status: 'confirmed', - senderAddress: selectedAddress, - marketId: 'market-123', - }, - showToast, - ); - - expect(showToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: 'Icon', - iconName: 'Check', - linkButtonOptions: expect.objectContaining({ - label: 'predict.order.view', - onPress: expect.any(Function), - }), - }), - ); - - const onView = showToast.mock.calls[0][0].linkButtonOptions.onPress; - onView(); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_DETAILS, - params: { marketId: 'market-123' }, - }); - }); - it('shows prediction placed toast without View button when marketId is absent', () => { const handler = getHandler(); @@ -869,38 +836,6 @@ describe('usePredictToastRegistrations', () => { ); }); - it('shows error toast with Try Again button when marketId is present', () => { - const handler = getHandler(); - - handler( - { - type: 'order', - status: 'failed', - senderAddress: selectedAddress, - marketId: 'market-456', - }, - showToast, - ); - - expect(showToast).toHaveBeenCalledWith( - expect.objectContaining({ - iconName: 'Error', - linkButtonOptions: expect.objectContaining({ - label: 'predict.order.try_again', - onPress: expect.any(Function), - }), - }), - ); - - const onRetry = showToast.mock.calls[0][0].linkButtonOptions.onPress; - onRetry(); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_DETAILS, - params: { marketId: 'market-456' }, - }); - }); - it('shows error toast without Try Again button when marketId is absent', () => { const handler = getHandler(); diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx index 428788d4d14..a9be3e18607 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx @@ -349,19 +349,6 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { }, ], hasNoTimeout: false, - ...(marketId - ? { - linkButtonOptions: { - label: strings('predict.order.view'), - onPress: () => { - navigation.navigate(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_DETAILS, - params: { marketId }, - }); - }, - }, - } - : {}), }); return; } @@ -371,17 +358,6 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { showToast, title: strings('predict.order.prediction_failed'), description: strings('predict.order.order_failed_generic'), - ...(marketId - ? { - retryLabel: strings('predict.order.try_again'), - onRetry: () => { - navigation.navigate(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_DETAILS, - params: { marketId }, - }); - }, - } - : {}), backgroundColor: theme.colors.accent04.normal, iconColor: theme.colors.error.default, }); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index c8865d53a63..9edf9c4b9d4 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -279,9 +279,11 @@ export class PolymarketProvider implements PredictProvider { throw new Error('Failed to parse market details'); } - return isSportsEvent + const result = isSportsEvent ? GameCache.getInstance().overlayOnMarket(parsedMarket) : parsedMarket; + + return result; } catch (error) { DevLogger.log('Error getting market details via Polymarket API:', error); throw error; @@ -937,8 +939,13 @@ export class PolymarketProvider implements PredictProvider { } const positionsData = (await response.json()) as PolymarketPosition[]; + const teamLookup = this.#createTeamLookup( + this.#getSupportedLeagues().length > 0, + ); + const parsedPositions = await parsePolymarketPositions({ positions: positionsData, + teamLookup, }); // Apply optimistic updates (unified for BUY/SELL/CLAIM) diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index 21d5863203a..9cf26a091d9 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -7,6 +7,7 @@ export interface PolymarketPosition { icon: string; title: string; slug: string; + eventSlug?: string; size: number; outcome: string; outcomeIndex: number; diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 311a40ec4e7..beb3b9bec0b 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -2314,6 +2314,135 @@ describe('polymarket utils', () => { 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', () => { diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index d29476f802d..feb995e356b 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -27,6 +27,10 @@ import { mapApiTeamToPredictTeam, type TeamLookup, } from '../../utils/gameParser'; +import { + isDrawCapableLeague, + SUPPORTED_SPORTS_LEAGUES, +} from '../../constants/sports'; import type { GetMarketsParams, OrderPreview, @@ -547,6 +551,17 @@ const sortOutcomeTokens = ( return outcomeTokens; }; +const getNegRiskYesTokenTitle = ( + market: PolymarketApiMarket, +): string | undefined => { + if (!market.negRisk || !isMoneylineMarket(market) || !market.groupItemTitle) { + return undefined; + } + return market.groupItemTitle.toLowerCase().startsWith('draw') + ? 'Draw' + : market.groupItemTitle; +}; + const parsePolymarketMarketOutcomes = ( market: PolymarketApiMarket, event: PolymarketApiEvent, @@ -558,10 +573,16 @@ const parsePolymarketMarketOutcomes = ( const outcomePrices = market.outcomePrices ? JSON.parse(market.outcomePrices) : []; + + const negRiskYesTitle = getNegRiskYesTokenTitle(market); + const outcomeTokens = outcomeTokensIds.map( (tokenId: string, index: number) => ({ id: tokenId, - title: outcomes[index], + title: + negRiskYesTitle && outcomes[index] === 'Yes' + ? negRiskYesTitle + : outcomes[index], price: parseFloat(outcomePrices[index]), }), ); @@ -674,6 +695,10 @@ export const parsePolymarketMarket = ( description: market.description, image: market.icon ?? market.image, groupItemTitle: formatMarketGroupItemTitle(market), + groupItemThreshold: + market.groupItemThreshold != null + ? Number(market.groupItemThreshold) + : undefined, status: market.closed ? PredictMarketStatus.CLOSED : PredictMarketStatus.OPEN, volume: market.volumeNum ?? 0, tokens: parsePolymarketMarketOutcomes(market, event), @@ -733,11 +758,9 @@ export const parsePolymarketEvents = ( // guaranteed to be accurate. They also do this on their webbsite. // // However, we noticed that the above statement is not correct, at least for game events. - const moneylineMarket = event.markets?.find((m) => isMoneylineMarket(m)); - const description = - moneylineMarket?.description ?? - event.markets?.[0]?.description ?? - event.description; + const description = game + ? event.description + : (event.markets?.[0]?.description ?? event.description); return { id: event.id, @@ -995,10 +1018,49 @@ export const getPredictPositionStatus = ({ return PredictPositionStatus.LOST; }; +const resolveNegRiskOutcomeLabel = ( + position: PolymarketPosition, + teamLookup?: TeamLookup, +): string | undefined => { + if (!position.negativeRisk || !position.eventSlug) { + return undefined; + } + + const league = SUPPORTED_SPORTS_LEAGUES.find( + (l) => isDrawCapableLeague(l) && position.eventSlug?.startsWith(`${l}-`), + ); + + if (!league) { + return undefined; + } + + const prefix = position.eventSlug + '-'; + if (!position.slug.startsWith(prefix)) { + return undefined; + } + + const outcomeToken = position.slug.slice(prefix.length); + if (!outcomeToken) { + return undefined; + } + + if (outcomeToken === 'draw') { + return 'Draw'; + } + + if (!teamLookup) { + return outcomeToken.toUpperCase(); + } + + return teamLookup(league, outcomeToken)?.name ?? outcomeToken.toUpperCase(); +}; + export const parsePolymarketPositions = async ({ positions, + teamLookup, }: { positions: PolymarketPosition[]; + teamLookup?: TeamLookup; }) => { const parsedPositions: PredictPosition[] = positions.map( (position: PolymarketPosition) => ({ @@ -1006,7 +1068,8 @@ export const parsePolymarketPositions = async ({ providerId: POLYMARKET_PROVIDER_ID, marketId: position.eventId, outcomeId: position.conditionId, - outcome: position.outcome, + outcome: + resolveNegRiskOutcomeLabel(position, teamLookup) ?? position.outcome, outcomeTokenId: position.asset, outcomeIndex: position.outcomeIndex, negRisk: position.negativeRisk, diff --git a/app/components/UI/Predict/selectors/predictController/index.test.ts b/app/components/UI/Predict/selectors/predictController/index.test.ts index 5aff1526292..9e632ac36e7 100644 --- a/app/components/UI/Predict/selectors/predictController/index.test.ts +++ b/app/components/UI/Predict/selectors/predictController/index.test.ts @@ -142,7 +142,29 @@ describe('Predict Controller Selectors', () => { }); describe('selectPredictActiveBuyOrder', () => { - it('returns active buy order when it exists', () => { + const mockAccountsController = { + internalAccounts: { + selectedAccount: 'acct-1', + accounts: { + 'acct-1': { + address: '0xabc123', + type: 'eip155:eoa', + id: 'acct-1', + metadata: { + name: 'Account 1', + keyring: { type: 'HD Key Tree' }, + importTime: 0, + lastSelected: 0, + }, + options: {}, + methods: [], + scopes: [], + }, + }, + }, + }; + + it('returns active buy order for the selected account address', () => { const activeBuyOrder = { state: 'preview', transactionId: 'tx-1', @@ -152,8 +174,9 @@ describe('Predict Controller Selectors', () => { engine: { backgroundState: { PredictController: { - activeBuyOrder, + activeBuyOrders: { '0xabc123': activeBuyOrder }, }, + AccountsController: mockAccountsController, }, }, }; @@ -164,13 +187,14 @@ describe('Predict Controller Selectors', () => { expect(result).toEqual(activeBuyOrder); }); - it('returns null when active buy order is null', () => { + it('returns null when no order exists for the selected address', () => { const mockState = { engine: { backgroundState: { PredictController: { - activeBuyOrder: null, + activeBuyOrders: {}, }, + AccountsController: mockAccountsController, }, }, }; @@ -186,6 +210,7 @@ describe('Predict Controller Selectors', () => { engine: { backgroundState: { PredictController: undefined, + AccountsController: mockAccountsController, }, }, }; diff --git a/app/components/UI/Predict/selectors/predictController/index.ts b/app/components/UI/Predict/selectors/predictController/index.ts index 984511ee6a3..266cbb3571a 100644 --- a/app/components/UI/Predict/selectors/predictController/index.ts +++ b/app/components/UI/Predict/selectors/predictController/index.ts @@ -1,6 +1,7 @@ import { createSelector } from 'reselect'; import { RootState } from '../../../../../reducers'; import { PredictPositionStatus } from '../../types'; +import { selectSelectedInternalAccountAddress } from '../../../../../selectors/accountsController'; const selectPredictControllerState = (state: RootState) => state.engine.backgroundState.PredictController; @@ -22,7 +23,11 @@ const selectPredictWithdrawTransaction = createSelector( const selectPredictActiveBuyOrder = createSelector( selectPredictControllerState, - (predictState) => predictState?.activeBuyOrder ?? null, + selectSelectedInternalAccountAddress, + (predictState, address) => { + if (!predictState || !address) return null; + return predictState.activeBuyOrders[address] ?? null; + }, ); const selectPredictClaimablePositions = createSelector( diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index c4cfd0dd16f..9fec4921292 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -129,7 +129,47 @@ export type PredictCategory = | 'hot'; // Sports league types -export type PredictSportsLeague = 'nfl' | 'nba'; +export type PredictSportsLeague = + | 'nfl' + | 'nba' + | 'ucl' + | 'fif' + | 'lal' + | 'uef' + | 'bra2' + | 'tur' + | 'col1' + | 'mls' + | 'mex' + | 'bun' + | 'chi' + | 'epl' + | 'cze1' + | 'j1100' + | 'j2100' + | 'fl1' + | 'nor' + | 'aus' + | 'den' + | 'sea' + | 'kor' + | 'ere' + | 'spl' + | 'bra' + | 'por' + | 'chi1' + | 'per1' + | 'lib' + | 'cdr' + | 'sud' + | 'egy1' + | 'uel' + | 'rou1' + | 'col' + | 'bol1' + | 'itc' + | 'dfb' + | 'cde'; // Game status export type PredictGameStatus = 'scheduled' | 'ongoing' | 'ended'; @@ -164,6 +204,10 @@ export type PredictGamePeriod = | 'OT' // Overtime | 'FT' // Final | 'VFT' // Verified fulltime (when closed=true) + | '1H' // First Half (soccer) + | '2H' // Second Half (soccer) + | 'ET' // Extra Time (soccer) + | 'PK' // Penalties (soccer) | (string & {}); // Escape hatch for future sports with different period formats // Game data attached to market @@ -209,6 +253,7 @@ export type PredictOutcome = { tokens: PredictOutcomeToken[]; volume: number; groupItemTitle: string; + groupItemThreshold?: number; negRisk?: boolean; tickSize?: string; resolvedBy?: string; diff --git a/app/components/UI/Predict/utils/gameParser.ts b/app/components/UI/Predict/utils/gameParser.ts index fb475c73b01..66f7000466f 100644 --- a/app/components/UI/Predict/utils/gameParser.ts +++ b/app/components/UI/Predict/utils/gameParser.ts @@ -10,12 +10,203 @@ import { PolymarketApiTeam, } from '../providers/polymarket/types'; -const NFL_SLUG_PATTERN = /^nfl-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/; -const NBA_SLUG_PATTERN = /^nba-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/; +type SlugTeamOrder = 'away-home' | 'home-away'; -const LEAGUE_SLUG_PATTERNS: Record = { - nfl: NFL_SLUG_PATTERN, - nba: NBA_SLUG_PATTERN, +interface LeagueSlugConfig { + pattern: RegExp; + teamOrder: SlugTeamOrder; + tagSlug?: string; // if different than league slug +} + +const LEAGUE_SLUG_CONFIGS: Record = { + nfl: { + pattern: /^nfl-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'away-home', + }, + nba: { + pattern: /^nba-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'away-home', + }, + ucl: { + pattern: /^ucl-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + fif: { + pattern: /^fif-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'fifa-friendly', + }, + lal: { + pattern: /^lal-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'la-liga', + }, + uef: { + pattern: /^uef-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'uef-qualifiers', + }, + bra2: { + pattern: /^bra2-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + tur: { + pattern: /^tur-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + col1: { + pattern: /^col1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + mls: { + pattern: /^mls-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + mex: { + pattern: /^mex-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + bun: { + pattern: /^bun-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'bundesliga', + }, + chi: { + pattern: /^chi-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'chinese-super-league', + }, + epl: { + pattern: /^epl-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'premier-league', + }, + cze1: { + pattern: /^cze1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + j1100: { + pattern: /^j1100-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'japan-j-league', + }, + j2100: { + pattern: /^j2100-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'japan-j2-league', + }, + fl1: { + pattern: /^fl1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'ligue-1', + }, + nor: { + pattern: /^nor-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'norway-eliteserien', + }, + aus: { + pattern: /^aus-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'australian-a-league', + }, + den: { + pattern: /^den-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'denmark-superliga', + }, + sea: { + pattern: /^sea-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + kor: { + pattern: /^kor-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'k-league', + }, + ere: { + pattern: /^ere-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + spl: { + pattern: /^spl-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'saudi-professional-league', + }, + bra: { + pattern: /^bra-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'brazil-serie-a', + }, + por: { + pattern: /^por-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'primeira-liga', + }, + chi1: { + pattern: /^chi1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + per1: { + pattern: /^per1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + lib: { + pattern: /^lib-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + cdr: { + pattern: /^cdr-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'copa-del-rey', + }, + sud: { + pattern: /^sud-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + egy1: { + pattern: /^egy1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + uel: { + pattern: /^uel-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + rou1: { + pattern: /^rou1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + col: { + pattern: /^col-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'europa-conference-league', + }, + bol1: { + pattern: /^bol1-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'soccer', + }, + itc: { + pattern: /^itc-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + dfb: { + pattern: /^dfb-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'dfb-pokal', + }, + cde: { + pattern: /^cde-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + tagSlug: 'coupe-de-france', + }, }; export type TeamLookup = ( @@ -38,10 +229,11 @@ export function getEventLeague( return null; } - const leagues = Object.keys(LEAGUE_SLUG_PATTERNS) as PredictSportsLeague[]; + const leagues = Object.keys(LEAGUE_SLUG_CONFIGS) as PredictSportsLeague[]; for (const league of leagues) { - const hasLeagueTag = tags.some((tag) => tag.slug === league); - const pattern = LEAGUE_SLUG_PATTERNS[league]; + const { pattern, tagSlug } = LEAGUE_SLUG_CONFIGS[league]; + const leagueTagSlug = tagSlug ?? league; + const hasLeagueTag = tags.some((tag) => tag.slug === leagueTagSlug); const hasValidSlug = pattern.test(event.slug); if (hasLeagueTag && hasValidSlug) { return league; @@ -63,17 +255,18 @@ export function parseGameSlugTeams( slug: string, league: PredictSportsLeague, ): ParsedGameSlug | null { - const pattern = LEAGUE_SLUG_PATTERNS[league]; - if (!pattern) { + const config = LEAGUE_SLUG_CONFIGS[league]; + if (!config) { return null; } - const match = slug.match(pattern); + const match = slug.match(config.pattern); if (!match) { return null; } + const isHomeFirst = config.teamOrder === 'home-away'; return { - awayAbbreviation: match[1], - homeAbbreviation: match[2], + awayAbbreviation: isHomeFirst ? match[2] : match[1], + homeAbbreviation: isHomeFirst ? match[1] : match[2], dateString: match[3], }; } @@ -92,6 +285,14 @@ export function formatPeriodDisplay(period: string): string { case 'FT': case 'VFT': return 'Final'; + case '1H': + return '1st Half'; + case '2H': + return '2nd Half'; + case 'ET': + return 'Extra Time'; + case 'PK': + return 'Penalties'; default: return period; } diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index 1d817c461cc..8ddb5707b20 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -40,7 +40,7 @@ import { usePredictBuyActions } from './hooks/usePredictBuyActions'; import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview'; import { usePredictOrderRetry } from '../../hooks/usePredictOrderRetry'; -import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder'; + import { selectPredictFakOrdersEnabledFlag, selectPredictWithAnyTokenEnabledFlag, @@ -62,8 +62,6 @@ const PredictBuyWithAnyToken = () => { const { market, outcome, outcomeToken, entryPoint } = route.params; const { isPlacingOrder } = usePredictActiveOrder(); - const { showOrderPlacedToast, invalidateOrderQueries } = - usePredictPlaceOrder(); const [isFeeBreakdownVisible, setIsFeeBreakdownVisible] = useState(false); @@ -175,8 +173,6 @@ const PredictBuyWithAnyToken = () => { analyticsProperties, preview, setIsConfirming, - showOrderPlacedToast, - invalidateOrderQueries, }); useEffect(() => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts index 8779e581774..c0c2572e703 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts @@ -13,11 +13,10 @@ const mockDispatch = jest.fn(); const mockOnConfirmActionsReject = jest.fn(); const mockOnApprovalConfirm = jest.fn(); const mockUnsubscribe = jest.fn(); -const mockShowOrderPlacedToast = jest.fn(); -const mockInvalidateOrderQueries = jest.fn(); +const mockSetSelectedPaymentToken = jest.fn(); +const mockOnPlaceOrderSuccess = jest.fn(); const mockTrackPredictOrderEvent = jest.fn(); const mockPlaceOrder = jest.fn, [PlaceOrderParams]>(); -const mockOnPlaceOrderEnd = jest.fn(); const mockOnOrderCancelled = jest.fn(); const mockInitPayWithAnyToken = jest.fn(); const mockSetIsConfirming = jest.fn(); @@ -88,9 +87,13 @@ jest.mock('../../../../../Views/confirmations/hooks/useConfirmActions', () => ({ }), })); +const mockClearActiveOrderTransactionId = jest.fn(); + jest.mock('../../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ activeOrder: mockActiveOrder, + clearActiveOrderTransactionId: (...args: unknown[]) => + mockClearActiveOrderTransactionId(...args), }), })); @@ -104,12 +107,17 @@ jest.mock('../../../hooks/usePredictTrading', () => ({ jest.mock('../../../../../../core/Engine', () => ({ context: { PredictController: { - onPlaceOrderEnd: (...args: unknown[]) => mockOnPlaceOrderEnd(...args), onOrderCancelled: (...args: unknown[]) => mockOnOrderCancelled(...args), trackPredictOrderEvent: (...args: unknown[]) => mockTrackPredictOrderEvent(...args), initPayWithAnyToken: (...args: unknown[]) => mockInitPayWithAnyToken(...args), + setSelectedPaymentToken: (...args: unknown[]) => + mockSetSelectedPaymentToken(...args), + onPlaceOrderSuccess: (...args: unknown[]) => + mockOnPlaceOrderSuccess(...args), + clearActiveOrderTransactionId: (...args: unknown[]) => + mockClearActiveOrderTransactionId(...args), }, }, })); @@ -132,8 +140,6 @@ const createDefaultParams = (): Parameters[0] => ({ } as OrderPreview, analyticsProperties: { marketId: 'market-1' }, setIsConfirming: mockSetIsConfirming, - showOrderPlacedToast: mockShowOrderPlacedToast, - invalidateOrderQueries: mockInvalidateOrderQueries, }); describe('usePredictBuyActions', () => { @@ -247,7 +253,6 @@ describe('usePredictBuyActions', () => { unmount(); expect(mockOnConfirmActionsReject).not.toHaveBeenCalled(); - expect(mockOnPlaceOrderEnd).not.toHaveBeenCalled(); }); }); @@ -460,14 +465,35 @@ describe('usePredictBuyActions', () => { }); describe('success effect', () => { - it('shows toast, cleans up and closes the screen in SUCCESS state', async () => { + it('calls onPlaceOrderSuccess when state is SUCCESS', () => { + mockActiveOrder = { state: ActiveOrderState.SUCCESS }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockOnPlaceOrderSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not navigate pop when handleConfirm was not called before SUCCESS', () => { mockActiveOrder = { state: ActiveOrderState.SUCCESS }; renderHook(() => usePredictBuyActions(createDefaultParams())); - expect(mockInvalidateOrderQueries).toHaveBeenCalledTimes(1); - expect(mockShowOrderPlacedToast).toHaveBeenCalledTimes(1); - expect(mockOnPlaceOrderEnd).toHaveBeenCalledTimes(1); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('navigates pop when handleConfirm was called before SUCCESS', async () => { + mockActiveOrder = { state: ActiveOrderState.PREVIEW }; + const { result, rerender } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + mockActiveOrder = { state: ActiveOrderState.SUCCESS }; + rerender(createDefaultParams()); + expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts index 6bc641596ac..862f19114f8 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts @@ -17,29 +17,28 @@ import { usePredictTrading } from '../../../hooks/usePredictTrading'; import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; import { PREDICT_ERROR_CODES } from '../../../constants/errors'; import { useConfirmActions } from '../../../../../Views/confirmations/hooks/useConfirmActions'; +import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; interface UsePredictBuyActionsParams { preview?: OrderPreview | null; analyticsProperties: PlaceOrderParams['analyticsProperties']; setIsConfirming: (value: boolean) => void; - showOrderPlacedToast: () => void; - invalidateOrderQueries: () => void; } export const usePredictBuyActions = ({ preview, analyticsProperties, setIsConfirming, - showOrderPlacedToast, - invalidateOrderQueries, }: UsePredictBuyActionsParams) => { const navigation = useNavigation>(); const { onConfirm: onApprovalConfirm, approvalRequest } = useApprovalRequest(); const { onReject } = useConfirmActions(); - const { activeOrder } = usePredictActiveOrder(); + const { activeOrder, clearActiveOrderTransactionId } = + usePredictActiveOrder(); const { placeOrder, initPayWithAnyToken } = usePredictTrading(); + const { resetSelectedPaymentToken } = usePredictPaymentToken(); const currentState = useMemo(() => activeOrder?.state, [activeOrder?.state]); const { PredictController } = Engine.context; const payWithAnyTokenEnabled = useSelector( @@ -47,6 +46,7 @@ export const usePredictBuyActions = ({ ); const hasInitializedPayWithAnyTokenRef = useRef(false); + const didInitiateOrderRef = useRef(false); useEffect(() => { const controller = Engine.context.PredictController; @@ -68,12 +68,19 @@ export const usePredictBuyActions = ({ const unsubscribe = navigation.addListener('transitionEnd', (e) => { if (!e.data.closing && !hasInitializedPayWithAnyTokenRef.current) { hasInitializedPayWithAnyTokenRef.current = true; + resetSelectedPaymentToken(); initPayWithAnyToken(); } }); return unsubscribe; - }, [navigation, initPayWithAnyToken, payWithAnyTokenEnabled]); + }, [ + navigation, + initPayWithAnyToken, + payWithAnyTokenEnabled, + PredictController, + resetSelectedPaymentToken, + ]); useEffect(() => { if (!payWithAnyTokenEnabled) { @@ -82,9 +89,14 @@ export const usePredictBuyActions = ({ return navigation.addListener('beforeRemove', () => { onReject(undefined, true); - PredictController.onPlaceOrderEnd(); + clearActiveOrderTransactionId(); }); - }, [navigation, payWithAnyTokenEnabled, PredictController, onReject]); + }, [ + navigation, + payWithAnyTokenEnabled, + onReject, + clearActiveOrderTransactionId, + ]); const handlePlaceOrder = useCallback( async (orderParams: PlaceOrderParams): Promise => { @@ -105,11 +117,9 @@ export const usePredictBuyActions = ({ ); const handleConfirm = useCallback(async () => { + didInitiateOrderRef.current = true; setIsConfirming(true); - // Only capture transactionId for PAY_WITH_ANY_TOKEN flow (deposit-order linking). - // Balance flow doesn't need it — passing undefined lets isCurrentActiveBuyOrder - // match without a strict transactionId check. const transactionId = currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN ? approvalRequest?.id @@ -162,19 +172,12 @@ export const usePredictBuyActions = ({ useEffect(() => { if (currentState === ActiveOrderState.SUCCESS) { - invalidateOrderQueries(); - showOrderPlacedToast(); - PredictController.onPlaceOrderEnd(); - navigation.dispatch(StackActions.pop()); + PredictController.onPlaceOrderSuccess(); + if (didInitiateOrderRef.current) { + navigation.dispatch(StackActions.pop()); + } } - }, [ - PredictController, - currentState, - invalidateOrderQueries, - navigation, - setIsConfirming, - showOrderPlacedToast, - ]); + }, [PredictController, currentState, navigation]); return { handleConfirm, diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 1ddd0a23f55..c5025a3b375 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -192,11 +192,15 @@ const PredictMarketDetails: React.FC = () => { } executeGuardedAction( () => { - // Use open outcomes with updated prices if available - const firstOpenOutcome = openOutcomes[0]; + const matchingOutcome = + market.outcomes.find((outcome) => + outcome.tokens.some((marketToken) => marketToken.id === token.id), + ) ?? + openOutcomes[0] ?? + market.outcomes?.[0]; navigateToBuyPreview({ market, - outcome: firstOpenOutcome ?? market.outcomes?.[0], + outcome: matchingOutcome, outcomeToken: token, entryPoint: entryPoint || PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS, diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index 681c0ea76d6..4972410a30b 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -354,13 +354,17 @@ describe('createBuildQuoteNavDetails', () => { }); }); +const mockSetSelectedProvider = jest.fn(); + describe('BuildQuote', () => { beforeEach(() => { jest.clearAllMocks(); mockUseParams.mockReturnValue({}); mockUseRampsController.mockReturnValue({ userRegion: USER_REGION, + providers: [WIDGET_PROVIDER, NATIVE_PROVIDER], selectedProvider: WIDGET_PROVIDER, + setSelectedProvider: mockSetSelectedProvider, selectedToken: SELECTED_TOKEN, paymentMethods: [SELECTED_PAYMENT_METHOD], getBuyWidgetData: mockGetBuyWidgetData, @@ -893,6 +897,176 @@ describe('BuildQuote', () => { }); }); + describe('auto-select provider when none selected', () => { + it('selects the first provider that supports the token', () => { + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + providers: [WIDGET_PROVIDER, NATIVE_PROVIDER], + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, + selectedToken: SELECTED_TOKEN, + paymentMethods: [SELECTED_PAYMENT_METHOD], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: SELECTED_PAYMENT_METHOD, + }); + + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).toHaveBeenCalledWith(WIDGET_PROVIDER, { + autoSelected: true, + }); + }); + + it('does not auto-select when a provider is already selected', () => { + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + }); + + it('does not auto-select when providers list is empty', () => { + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + providers: [], + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, + selectedToken: SELECTED_TOKEN, + paymentMethods: [], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: null, + }); + + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + }); + + it('does not auto-select when no token is selected', () => { + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + providers: [WIDGET_PROVIDER, NATIVE_PROVIDER], + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, + selectedToken: null, + paymentMethods: [], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: null, + }); + + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + }); + + it('skips providers that do not support the token', () => { + const unsupportedProvider = { + id: 'banxa', + name: 'Banxa', + supportedCryptoCurrencies: { 'eip155:1/erc20:0xother': true }, + links: [], + }; + + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + providers: [unsupportedProvider, NATIVE_PROVIDER], + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, + selectedToken: SELECTED_TOKEN, + paymentMethods: [SELECTED_PAYMENT_METHOD], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: SELECTED_PAYMENT_METHOD, + }); + + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).toHaveBeenCalledWith(NATIVE_PROVIDER, { + autoSelected: true, + }); + }); + + it('does not auto-select when no provider supports the token', () => { + const unsupportedProvider = { + id: 'banxa', + name: 'Banxa', + supportedCryptoCurrencies: { 'eip155:1/erc20:0xother': true }, + links: [], + }; + + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + providers: [unsupportedProvider], + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, + selectedToken: SELECTED_TOKEN, + paymentMethods: [], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: null, + }); + + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + }); + + it('does not auto-select when screen is not focused', () => { + const mockUseIsFocused = jest.requireMock('@react-navigation/native') + .useIsFocused as jest.Mock; + mockUseIsFocused.mockReturnValue(false); + + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + providers: [WIDGET_PROVIDER], + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, + selectedToken: SELECTED_TOKEN, + paymentMethods: [SELECTED_PAYMENT_METHOD], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: SELECTED_PAYMENT_METHOD, + }); + + renderWithProvider(, { state: initialRootState }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + + mockUseIsFocused.mockReturnValue(true); + }); + }); + describe('Token unavailable for provider', () => { const TOKEN_ASSET = 'eip155:1/slip44:60'; @@ -906,7 +1080,9 @@ describe('BuildQuote', () => { const mockUnavailableController = (overrides: Record) => { mockUseRampsController.mockReturnValue({ userRegion: USER_REGION, + providers: [transakProvider, WIDGET_PROVIDER], selectedProvider: transakProvider, + setSelectedProvider: mockSetSelectedProvider, selectedToken: SELECTED_TOKEN, paymentMethods: [], getBuyWidgetData: mockGetBuyWidgetData, @@ -1089,5 +1265,117 @@ describe('BuildQuote', () => { }), ); }); + + describe('auto-switch when providerAutoSelected', () => { + const BTC_ASSET = 'eip155:1/slip44:0'; + + const paypalProvider = { + id: '/providers/paypal', + name: 'PayPal', + supportedCryptoCurrencies: { [TOKEN_ASSET]: true, [BTC_ASSET]: false }, + links: [], + }; + + const coinbaseProvider = { + id: '/providers/coinbase', + name: 'Coinbase', + supportedCryptoCurrencies: { [TOKEN_ASSET]: true, [BTC_ASSET]: true }, + links: [], + }; + + const autoSelectedState = { + ...initialRootState, + engine: { + ...initialRootState.engine, + backgroundState: { + ...initialRootState.engine.backgroundState, + RampsController: { + ...initialRootState.engine.backgroundState.RampsController, + providerAutoSelected: true, + }, + }, + }, + }; + + it('auto-switches to a supporting provider instead of showing the modal', () => { + mockUnavailableController({ + selectedProvider: paypalProvider, + providers: [paypalProvider, coinbaseProvider], + selectedToken: { + assetId: BTC_ASSET, + chainId: 'eip155:1', + symbol: 'BTC', + }, + }); + mockUseParams.mockReturnValue({ assetId: BTC_ASSET }); + + renderWithProvider(, { state: autoSelectedState }); + act(() => { + jest.advanceTimersByTime(650); + }); + + expect(mockSetSelectedProvider).toHaveBeenCalledWith(coinbaseProvider, { + autoSelected: true, + }); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + + it('falls back to modal when no provider supports the token', () => { + mockUnavailableController({ + selectedProvider: paypalProvider, + providers: [paypalProvider], + selectedToken: { + assetId: BTC_ASSET, + chainId: 'eip155:1', + symbol: 'BTC', + }, + }); + mockUseParams.mockReturnValue({ assetId: BTC_ASSET }); + + renderWithProvider(, { state: autoSelectedState }); + act(() => { + jest.advanceTimersByTime(650); + }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + + it('shows modal when provider was not auto-selected', () => { + mockUnavailableController({ + selectedProvider: paypalProvider, + providers: [paypalProvider, coinbaseProvider], + selectedToken: { + assetId: BTC_ASSET, + chainId: 'eip155:1', + symbol: 'BTC', + }, + }); + mockUseParams.mockReturnValue({ assetId: BTC_ASSET }); + + renderWithProvider(, { state: initialRootState }); + act(() => { + jest.advanceTimersByTime(650); + }); + + expect(mockSetSelectedProvider).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + }); }); }); diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 85b3733c69f..9d7881f7efa 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -81,6 +81,7 @@ import { getRampRoutingDecision, UnifiedRampRoutingType, } from '../../../../../reducers/fiatOrders'; +import { selectProviderAutoSelected } from '../../../../../selectors/rampsController'; import Device from '../../../../../util/device'; import TruncatedError from '../../components/TruncatedError'; import { PROVIDER_LINKS } from '../../Aggregator/types'; @@ -171,7 +172,9 @@ function BuildQuote() { const { userRegion, + providers, selectedProvider, + setSelectedProvider, selectedToken, paymentMethods, getBuyWidgetData, @@ -184,6 +187,7 @@ function BuildQuote() { const { trackEvent, createEventBuilder } = useAnalytics(); const rampRoutingDecision = useSelector(getRampRoutingDecision); + const providerAutoSelected = useSelector(selectProviderAutoSelected); const prevSelectedProviderRef = useRef(selectedProvider); /* @@ -257,16 +261,53 @@ function BuildQuote() { }, []), ); - // Show "Token Not Available" modal when the selected token is unavailable - // for the current provider. Debounced to let the query settle — prevents - // the modal from flashing when isTokenUnavailable is briefly true due to - // stale cached data before the fresh response arrives. + // When no provider is selected (e.g. first-time user in a region without + // Transak), pick the first provider that supports the selected token. + useEffect(() => { + if ( + !isOnBuildQuoteScreen || + selectedProvider || + !effectiveAssetId || + providers.length === 0 + ) { + return; + } + const supportingProvider = providers.find( + (p) => p.supportedCryptoCurrencies?.[effectiveAssetId] === true, + ); + if (supportingProvider) { + setSelectedProvider(supportingProvider, { autoSelected: true }); + } + }, [ + isOnBuildQuoteScreen, + selectedProvider, + effectiveAssetId, + providers, + setSelectedProvider, + ]); + + // When the selected token is unavailable for the current provider: + // - If the provider was auto-selected (soft), silently switch to the best + // provider that supports the token. + // - Otherwise, show the "Token Not Available" modal so the user can decide. useEffect(() => { if (!isOnBuildQuoteScreen || !isTokenUnavailable) { lastShownUnavailableKeyRef.current = ''; return; } + if (providerAutoSelected && effectiveAssetId) { + const supportingProvider = providers.find( + (p) => + p.id !== selectedProvider?.id && + p.supportedCryptoCurrencies?.[effectiveAssetId] === true, + ); + if (supportingProvider) { + setSelectedProvider(supportingProvider, { autoSelected: true }); + return; + } + } + const key = `${selectedProvider?.id}:${effectiveAssetId}`; if (lastShownUnavailableKeyRef.current === key) return; @@ -289,6 +330,9 @@ function BuildQuote() { navigation, selectedProvider?.id, focusTrigger, + providerAutoSelected, + providers, + setSelectedProvider, ]); const { diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts index 5814e419158..e5bbe06e335 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts @@ -222,7 +222,7 @@ describe('useRampsProviders', () => { expect( Engine.context.RampsController.setSelectedProvider, - ).toHaveBeenCalledWith(mockProviders[0].id); + ).toHaveBeenCalledWith(mockProviders[0].id, undefined); }); it('calls Engine.context.RampsController.setSelectedProvider with null when provider is null', () => { @@ -237,7 +237,24 @@ describe('useRampsProviders', () => { expect( Engine.context.RampsController.setSelectedProvider, - ).toHaveBeenCalledWith(null); + ).toHaveBeenCalledWith(null, undefined); + }); + + it('forwards options to the controller', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsProviders(), { + wrapper: wrapper(store), + }); + + act(() => { + result.current.setSelectedProvider(mockProviders[0], { + autoSelected: true, + }); + }); + + expect( + Engine.context.RampsController.setSelectedProvider, + ).toHaveBeenCalledWith(mockProviders[0].id, { autoSelected: true }); }); }); @@ -251,7 +268,10 @@ describe('useRampsProviders', () => { it('calls determinePreferredProvider with completed orders and providers when providers exist and selectedProvider is null', () => { const store = createMockStore({ data: mockProviders }); mockGetOrders.mockReturnValue(emptyOrders); - mockDeterminePreferredProvider.mockReturnValue(mockProviders[0]); + mockDeterminePreferredProvider.mockReturnValue({ + provider: mockProviders[0], + autoSelected: false, + }); renderHook(() => useRampsProviders(), { wrapper: wrapper(store), @@ -266,7 +286,10 @@ describe('useRampsProviders', () => { it('calls setSelectedProvider with result of determinePreferredProvider when providers exist and selectedProvider is null', () => { const store = createMockStore({ data: mockProviders }); mockGetOrders.mockReturnValue(emptyOrders); - mockDeterminePreferredProvider.mockReturnValue(mockProviders[1]); + mockDeterminePreferredProvider.mockReturnValue({ + provider: mockProviders[1], + autoSelected: false, + }); renderHook(() => useRampsProviders(), { wrapper: wrapper(store), @@ -274,7 +297,21 @@ describe('useRampsProviders', () => { expect( Engine.context.RampsController.setSelectedProvider, - ).toHaveBeenCalledWith(mockProviders[1].id); + ).toHaveBeenCalledWith(mockProviders[1].id, { autoSelected: false }); + }); + + it('does not call setSelectedProvider when determinePreferredProvider returns null', () => { + const store = createMockStore({ data: mockProviders }); + mockGetOrders.mockReturnValue(emptyOrders); + mockDeterminePreferredProvider.mockReturnValue(null); + + renderHook(() => useRampsProviders(), { + wrapper: wrapper(store), + }); + + expect( + Engine.context.RampsController.setSelectedProvider, + ).not.toHaveBeenCalled(); }); it('does not call determinePreferredProvider when providers is empty', () => { diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 41c10f2dcb8..a8d2038a43b 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -31,8 +31,13 @@ export interface UseRampsProvidersResult { /** * Sets the selected provider by ID. * @param provider - The provider to select, or null to clear selection. + * @param options - Optional settings forwarded to the controller. + * @param options.autoSelected - When true the selection is treated as a system guess rather than an explicit user choice. */ - setSelectedProvider: (provider: Provider | null) => void; + setSelectedProvider: ( + provider: Provider | null, + options?: { autoSelected?: boolean }, + ) => void; /** * Whether the providers request is currently loading. */ @@ -83,18 +88,24 @@ export function useRampsProviders(): UseRampsProvidersResult { ); const setSelectedProvider = useCallback( - (provider: Provider | null) => - Engine.context.RampsController.setSelectedProvider(provider?.id ?? null), + (provider: Provider | null, options?: { autoSelected?: boolean }) => + Engine.context.RampsController.setSelectedProvider( + provider?.id ?? null, + options, + ), [], ); useEffect(() => { if (providers && providers.length > 0 && !selectedProvider) { - setSelectedProvider( - determinePreferredProvider(completedOrders, providers), - ); + const result = determinePreferredProvider(completedOrders, providers); + if (result) { + Engine.context.RampsController.setSelectedProvider(result.provider.id, { + autoSelected: result.autoSelected, + }); + } } - }, [providers, selectedProvider, setSelectedProvider, completedOrders]); + }, [providers, selectedProvider, completedOrders]); return { providers, diff --git a/app/components/UI/Ramp/utils/determinePreferredProvider.test.ts b/app/components/UI/Ramp/utils/determinePreferredProvider.test.ts index 8e8736ee1a1..fbcd9ba86b7 100644 --- a/app/components/UI/Ramp/utils/determinePreferredProvider.test.ts +++ b/app/components/UI/Ramp/utils/determinePreferredProvider.test.ts @@ -179,7 +179,7 @@ describe('determinePreferredProvider', () => { }); describe('when user has completed orders', () => { - it('returns provider from most recent completed order', () => { + it('returns provider from most recent completed order with autoSelected false', () => { const completedOrders = [ { providerId: 'provider-1', completedAt: 1000 }, { providerId: 'provider-2', completedAt: 2000 }, @@ -188,10 +188,13 @@ describe('determinePreferredProvider', () => { const result = determinePreferredProvider(completedOrders, providers); - expect(result).toEqual(mockProvider2); + expect(result).toEqual({ + provider: mockProvider2, + autoSelected: false, + }); }); - it('falls back to Transak if provider from order is not in available providers', () => { + it('falls back to Transak with autoSelected false if provider from order is not in available providers', () => { const completedOrders = [ { providerId: 'non-existent-provider', completedAt: 1000 }, ]; @@ -199,25 +202,31 @@ describe('determinePreferredProvider', () => { const result = determinePreferredProvider(completedOrders, providers); - expect(result).toEqual(mockTransakProvider); + expect(result).toEqual({ + provider: mockTransakProvider, + autoSelected: false, + }); }); }); describe('when user has no orders', () => { - it('returns Transak provider if available', () => { + it('returns Transak provider with autoSelected false', () => { const providers = [mockProvider1, mockProvider2, mockTransakProvider]; const result = determinePreferredProvider([], providers); - expect(result).toEqual(mockTransakProvider); + expect(result).toEqual({ + provider: mockTransakProvider, + autoSelected: false, + }); }); - it('returns first provider if Transak is not available', () => { + it('returns null if Transak is not available (no preselection without signal)', () => { const providers = [mockProvider1, mockProvider2]; const result = determinePreferredProvider([], providers); - expect(result).toEqual(mockProvider1); + expect(result).toBeNull(); }); }); @@ -228,7 +237,10 @@ describe('determinePreferredProvider', () => { const result = determinePreferredProvider(completedOrders, providers); - expect(result).toEqual(mockProvider1); + expect(result).toEqual({ + provider: mockProvider1, + autoSelected: false, + }); }); it('matches provider by name case-insensitively', () => { @@ -239,7 +251,10 @@ describe('determinePreferredProvider', () => { const result = determinePreferredProvider(completedOrders, providers); - expect(result).toEqual(mockProvider1); + expect(result).toEqual({ + provider: mockProvider1, + autoSelected: false, + }); }); it('matches Transak by id containing "transak"', () => { @@ -252,7 +267,10 @@ describe('determinePreferredProvider', () => { const result = determinePreferredProvider(completedOrders, providers); - expect(result).toEqual(transakVariant); + expect(result).toEqual({ + provider: transakVariant, + autoSelected: false, + }); }); }); @@ -272,7 +290,10 @@ describe('determinePreferredProvider', () => { providers, ); - expect(result).toEqual(mockProvider2); + expect(result).toEqual({ + provider: mockProvider2, + autoSelected: false, + }); }); it('works with controller RampsOrders through converter', () => { @@ -289,7 +310,10 @@ describe('determinePreferredProvider', () => { providers, ); - expect(result).toEqual(mockProvider2); + expect(result).toEqual({ + provider: mockProvider2, + autoSelected: false, + }); }); it('picks most recent across both legacy and controller orders', () => { @@ -316,7 +340,10 @@ describe('determinePreferredProvider', () => { providers, ); - expect(result).toEqual(mockProvider2); + expect(result).toEqual({ + provider: mockProvider2, + autoSelected: false, + }); }); }); }); diff --git a/app/components/UI/Ramp/utils/determinePreferredProvider.ts b/app/components/UI/Ramp/utils/determinePreferredProvider.ts index ef6f6ed6779..35baa914243 100644 --- a/app/components/UI/Ramp/utils/determinePreferredProvider.ts +++ b/app/components/UI/Ramp/utils/determinePreferredProvider.ts @@ -66,22 +66,29 @@ export function completedOrdersFromRampsOrders( }, []); } +export interface PreferredProviderResult { + provider: Provider; + autoSelected: boolean; +} + /** * Determines the preferred provider based on user's completed order history. * * Fallback order: - * 1. Provider from most recent completed order - * 2. Transak - * 3. First available provider + * 1. Provider from most recent completed order (autoSelected: false) + * 2. Transak (autoSelected: false) + * 3. null — no preselection; wait for the user to pick a token, then + * choose the first provider that supports it. * * @param completedOrders - Completed orders from any source (legacy + controller) * @param availableProviders - Available providers from RampsController - * @returns The preferred provider, or null if no providers are available + * @returns The preferred provider with its selection source, or null if no + * providers are available or no signal exists to pick one. */ export function determinePreferredProvider( completedOrders: CompletedOrderInfo[], availableProviders: Provider[], -): Provider | null { +): PreferredProviderResult | null { if (availableProviders.length === 0) { return null; } @@ -98,7 +105,7 @@ export function determinePreferredProvider( ); if (foundProvider) { - return foundProvider; + return { provider: foundProvider, autoSelected: false }; } } @@ -109,8 +116,8 @@ export function determinePreferredProvider( ); if (transakProvider) { - return transakProvider; + return { provider: transakProvider, autoSelected: false }; } - return availableProviders[0]; + return null; } diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx index 92525962240..54d8b9bbac3 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx @@ -8,7 +8,7 @@ import { createMockAccountsControllerState } from '../../../../../util/test/acco import { backgroundState } from '../../../../../util/test/initial-root-state'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; -import { StakeConfirmationViewProps } from './StakeConfirmationView.types'; +import { StakeConfirmationViewRouteParams } from './StakeConfirmationView.types'; import { MOCK_POOL_STAKING_SDK } from '../../__mocks__/stakeMockData'; import { RootState } from '../../../../../reducers'; @@ -81,6 +81,18 @@ jest.mock('@react-navigation/native', () => { navigate: jest.fn(), setOptions: jest.fn(), }), + useRoute: () => ({ + key: '1', + name: 'params', + params: { + amountWei: '10000000000000000', + amountFiat: '26.21', + annualRewardRate: '2.6%', + annualRewardsETH: '0.00026 ETH', + annualRewardsFiat: '$0.68', + chainId: '1', + } as StakeConfirmationViewRouteParams, + }), }; }); @@ -116,24 +128,9 @@ expect.addSnapshotSerializer({ describe('StakeConfirmationView', () => { it('render matches snapshot', () => { - const props: StakeConfirmationViewProps = { - route: { - key: '1', - params: { - amountWei: '10000000000000000', - amountFiat: '26.21', - annualRewardRate: '2.6%', - annualRewardsETH: '0.00026 ETH', - annualRewardsFiat: '$0.68', - chainId: '1', - }, - name: 'params', - }, - }; - const { toJSON } = renderWithProvider( - + , ); diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx index 47362a7b22e..5749e9d0040 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { useStyles } from '../../../../hooks/useStyles'; import { getStakingNavbar } from '../../../Navbar'; import styleSheet from './StakeConfirmationView.styles'; @@ -8,7 +8,7 @@ import TokenValueStack from '../../components/StakingConfirmation/TokenValueStac import AccountCard from '../../components/StakingConfirmation/AccountCard/AccountCard'; import RewardsCard from '../../components/StakingConfirmation/RewardsCard/RewardsCard'; import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter'; -import { StakeConfirmationViewProps } from './StakeConfirmationView.types'; +import { StakeConfirmationViewRouteParams } from './StakeConfirmationView.types'; import { strings } from '../../../../../../locales/i18n'; import { FooterButtonGroupActions } from '../../components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.types'; import UnstakingTimeCard from '../../components/StakingConfirmation/UnstakeTimeCard/UnstakeTimeCard'; @@ -19,8 +19,12 @@ import { getDecimalChainId } from '../../../../../util/networks'; const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking'; -const StakeConfirmationView = ({ route }: StakeConfirmationViewProps) => { +const StakeConfirmationView = () => { const navigation = useNavigation(); + const route = + useRoute< + RouteProp<{ params: StakeConfirmationViewRouteParams }, 'params'> + >(); const { styles, theme } = useStyles(styleSheet, {}); diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts index 361cd0346fb..1de1e792314 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts @@ -1,6 +1,4 @@ -import { RouteProp } from '@react-navigation/native'; - -interface StakeConfirmationViewRouteParams { +export interface StakeConfirmationViewRouteParams { amountWei: string; amountFiat: string; annualRewardsETH: string; @@ -8,7 +6,3 @@ interface StakeConfirmationViewRouteParams { annualRewardRate: string; chainId: string; } - -export interface StakeConfirmationViewProps { - route: RouteProp<{ params: StakeConfirmationViewRouteParams }, 'params'>; -} diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx index 3e130cacbd7..f1f51776d0e 100644 --- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx +++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx @@ -6,7 +6,7 @@ import renderWithProvider, { import { Image, ImageSize } from 'react-native'; import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { UnstakeConfirmationViewProps } from './UnstakeConfirmationView.types'; +import { UnstakeConfirmationViewRouteParams } from './UnstakeConfirmationView.types'; import { MOCK_POOL_STAKING_SDK } from '../../__mocks__/stakeMockData'; import { RootState } from '../../../../../reducers'; @@ -72,6 +72,14 @@ jest.mock('@react-navigation/native', () => { navigate: mockNavigate, setOptions: mockSetOptions, }), + useRoute: () => ({ + key: '1', + name: 'params', + params: { + amountWei: '4999820000000000000', + amountFiat: '12894.52', + } as UnstakeConfirmationViewRouteParams, + }), }; }); @@ -107,21 +115,9 @@ expect.addSnapshotSerializer({ describe('UnstakeConfirmationView', () => { it('render matches snapshot', () => { - const props: UnstakeConfirmationViewProps = { - route: { - key: '1', - name: 'params', - params: { - amountWei: '4999820000000000000', - amountFiat: '12894.52', - }, - }, - }; - - const { toJSON } = renderWithProvider( - , - { state: mockInitialState }, - ); + const { toJSON } = renderWithProvider(, { + state: mockInitialState, + }); expect(toJSON()).toMatchSnapshot(); }); diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx index 695160cf642..d7570cb9010 100644 --- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx +++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx @@ -1,12 +1,12 @@ import { View } from 'react-native'; import React, { useEffect } from 'react'; -import { useNavigation } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import styleSheet from './UnstakeConfirmationView.styles'; import { useStyles } from '../../../../hooks/useStyles'; import { getStakingNavbar } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; import UnstakingTimeCard from '../../components/StakingConfirmation/UnstakeTimeCard/UnstakeTimeCard'; -import { UnstakeConfirmationViewProps } from './UnstakeConfirmationView.types'; +import { UnstakeConfirmationViewRouteParams } from './UnstakeConfirmationView.types'; import TokenValueStack from '../../components/StakingConfirmation/TokenValueStack/TokenValueStack'; import AccountCard from '../../components/StakingConfirmation/AccountCard/AccountCard'; import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter'; @@ -19,7 +19,11 @@ import { selectEvmChainId } from '../../../../../selectors/networkController'; const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking'; -const UnstakeConfirmationView = ({ route }: UnstakeConfirmationViewProps) => { +const UnstakeConfirmationView = () => { + const route = + useRoute< + RouteProp<{ params: UnstakeConfirmationViewRouteParams }, 'params'> + >(); const { styles, theme } = useStyles(styleSheet, {}); const chainId = useSelector(selectEvmChainId); const navigation = useNavigation(); diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.types.ts b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.types.ts index 3b8a1bf4c13..10f362ddfa5 100644 --- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.types.ts +++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.types.ts @@ -1,6 +1,6 @@ import { RouteProp } from '@react-navigation/native'; -interface UnstakeConfirmationViewRouteParams { +export interface UnstakeConfirmationViewRouteParams { amountWei: string; amountFiat: string; } diff --git a/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx b/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx index 72673171db0..f6d3da723a3 100644 --- a/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx +++ b/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx @@ -10,18 +10,34 @@ import { strings } from '../../../../../../locales/i18n'; import { flushPromises } from '../../../../../util/test/utils'; import usePoolStakedDeposit from '../../hooks/usePoolStakedDeposit'; -import { GasImpactModalProps } from './GasImpactModal.types'; +import { GasImpactModalRouteParams } from './GasImpactModal.types'; import GasImpactModal from './index'; const MOCK_SELECTED_INTERNAL_ACCOUNT = { address: '0x123', } as InternalAccount; +const mockRouteParams: GasImpactModalRouteParams = { + amountWei: '3210000000000000', + amountFiat: '7.46', + annualRewardRate: '2.5%', + annualRewardsETH: '2.5 ETH', + annualRewardsFiat: '$5000', + estimatedGasFee: '0.009171428571428572', + estimatedGasFeePercentage: '35%', + chainId: '1', +}; + jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); return { ...actualReactNavigation, useNavigation: jest.fn(), + useRoute: () => ({ + key: '1', + name: 'params', + params: mockRouteParams, + }), }; }); @@ -64,23 +80,6 @@ jest.mock('../../../../hooks/useMetrics', () => ({ }, })); -const props: GasImpactModalProps = { - route: { - key: '1', - params: { - amountWei: '3210000000000000', - amountFiat: '7.46', - annualRewardRate: '2.5%', - annualRewardsETH: '2.5 ETH', - annualRewardsFiat: '$5000', - estimatedGasFee: '0.009171428571428572', - estimatedGasFeePercentage: '35%', - chainId: '1', - }, - name: 'params', - }, -}; - const initialMetrics: Metrics = { frame: { x: 0, y: 0, width: 320, height: 640 }, insets: { top: 0, left: 0, right: 0, bottom: 0 }, @@ -89,7 +88,7 @@ const initialMetrics: Metrics = { const renderGasImpactModal = () => renderWithProvider( - , + , , undefined, true, @@ -177,7 +176,7 @@ describe('GasImpactModal', () => { expect(attemptDepositTransactionMock).toHaveBeenCalledTimes(1); expect(attemptDepositTransactionMock).toHaveBeenCalledWith( - props.route.params.amountWei, + mockRouteParams.amountWei, MOCK_SELECTED_INTERNAL_ACCOUNT.address, ); }); diff --git a/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.types.ts b/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.types.ts index d833bf8c98a..194e54d3bc6 100644 --- a/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.types.ts +++ b/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.types.ts @@ -1,6 +1,4 @@ -import { RouteProp } from '@react-navigation/native'; - -interface GasImpactModalRouteParams { +export interface GasImpactModalRouteParams { amountWei: string; amountFiat: string; annualRewardsETH: string; @@ -10,7 +8,3 @@ interface GasImpactModalRouteParams { estimatedGasFeePercentage: string; chainId: string; } - -export interface GasImpactModalProps { - route: RouteProp<{ params: GasImpactModalRouteParams }, 'params'>; -} diff --git a/app/components/UI/Stake/components/GasImpactModal/index.tsx b/app/components/UI/Stake/components/GasImpactModal/index.tsx index 21fdc55981b..42fe14735f7 100644 --- a/app/components/UI/Stake/components/GasImpactModal/index.tsx +++ b/app/components/UI/Stake/components/GasImpactModal/index.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef } from 'react'; import { formatEther } from 'ethers/lib/utils'; import { useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { View } from 'react-native'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; @@ -24,14 +24,16 @@ import { import styleSheet from './GasImpactModal.styles'; import { useStyles } from '../../../../hooks/useStyles'; import Routes from '../../../../../constants/navigation/Routes'; -import { GasImpactModalProps } from './GasImpactModal.types'; +import { GasImpactModalRouteParams } from './GasImpactModal.types'; import { strings } from '../../../../../../locales/i18n'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events'; import usePoolStakedDeposit from '../../hooks/usePoolStakedDeposit'; import { EVM_SCOPE } from '../../../Earn/constants/networks'; -const GasImpactModal = ({ route }: GasImpactModalProps) => { +const GasImpactModal = () => { + const route = + useRoute>(); const { styles } = useStyles(styleSheet, {}); // TODO: Remove dead code as we are not using the legacy confirmations anymore const isStakingDepositRedesignedEnabled = true; diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 36b0de58aa1..32e68bca870 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -38,7 +38,6 @@ import { import { selectPrimaryCurrency } from '../../../selectors/settings'; import { baseStyles, fontStyles } from '../../../styles/common'; import { isHardwareAccount } from '../../../util/address'; -import { decGWEIToHexWEI } from '../../../util/conversions'; import Device from '../../../util/device'; import Logger from '../../../util/Logger'; import { @@ -47,12 +46,16 @@ import { getBlockExplorerAddressUrl, getBlockExplorerName, } from '../../../util/networks'; -import { addHexPrefix } from '../../../util/number'; import { mockTheme, ThemeContext } from '../../../util/theme'; import { + getPreviousGasFromController, speedUpTransaction, updateIncomingTransactions, } from '../../../util/transaction-controller'; +import { + getGasValuesForReplacement, + getMediumGasPriceHex, +} from '../../../util/confirmation/gas'; import { validateTransactionActionBalance } from '../../../util/transactions'; import { createLedgerTransactionModalNavDetails } from '../../UI/LedgerModals/LedgerTransactionModal'; import { createQRSigningTransactionModalNavDetails } from '../../UI/QRHardware/QRSigningTransactionModal'; @@ -531,7 +534,12 @@ class Transactions extends PureComponent { ExtendedKeyringTypes.ledger, ]); - const params = this.getParamsToSend(transactionObject); + const rawParams = this.getParamsToSend(transactionObject); + const params = getGasValuesForReplacement( + rawParams, + getPreviousGasFromController(this.speedUpTxId), + SPEED_UP_RATE, + ); if (isLedgerAccount) { const isEip1559 = params?.maxFeePerGas && params?.maxPriorityFeePerGas; await this.signLedgerTransaction({ @@ -604,7 +612,12 @@ class Transactions extends PureComponent { ExtendedKeyringTypes.ledger, ]); - const params = this.getParamsToSend(transactionObject); + const rawParams = this.getParamsToSend(transactionObject); + const params = getGasValuesForReplacement( + rawParams, + getPreviousGasFromController(this.cancelTxId), + CANCEL_RATE, + ); if (isLedgerAccount) { const isEip1559 = params?.maxFeePerGas && params?.maxPriorityFeePerGas; await this.signLedgerTransaction({ @@ -799,19 +812,7 @@ class Transactions extends PureComponent { } } - return { gasPrice: this.getGasPriceEstimate() }; - } - - getGasPriceEstimate() { - const { gasFeeEstimates } = this.props; - - const estimateGweiDecimal = - gasFeeEstimates?.medium?.suggestedMaxFeePerGas ?? - gasFeeEstimates?.medium ?? - gasFeeEstimates.gasPrice ?? - '0'; - - return addHexPrefix(decGWEIToHexWEI(estimateGweiDecimal)); + return { gasPrice: getMediumGasPriceHex(this.props.gasFeeEstimates) }; } } diff --git a/app/components/UI/Transactions/index.test.tsx b/app/components/UI/Transactions/index.test.tsx index b5afe11a75b..b26fe53713e 100644 --- a/app/components/UI/Transactions/index.test.tsx +++ b/app/components/UI/Transactions/index.test.tsx @@ -55,6 +55,12 @@ jest.mock('../../../core/NotificationManager', () => ({ jest.mock('../../../util/transaction-controller', () => ({ updateIncomingTransactions: jest.fn(), speedUpTransaction: jest.fn(), + getPreviousGasFromController: jest.fn(() => undefined), +})); + +jest.mock('../../../util/confirmation/gas', () => ({ + getGasValuesForReplacement: jest.fn((gasValues) => gasValues), + getMediumGasPriceHex: jest.fn(() => '0x123'), })); jest.mock('../../../core/Engine', () => ({ @@ -1423,14 +1429,6 @@ describe('UnconnectedTransactions Component Direct Method Testing', () => { const key = instance.keyExtractor({ id: 'tx-123' }); expect(key).toBe('tx-123'); - // Test getGasPriceEstimate method directly - instance.props = { - ...defaultTestProps, - gasFeeEstimates: { medium: { suggestedMaxFeePerGas: '20' } }, - }; - const estimate = instance.getGasPriceEstimate(); - expect(estimate).toBeDefined(); - // Test getCancelOrSpeedupValues (no arg; derives from existingTx) instance.existingTx = { txParams: { gasPrice: '0x0' } }; const result = instance.getCancelOrSpeedupValues(); @@ -2149,46 +2147,6 @@ describe('UnconnectedTransactions Component Direct Method Testing', () => { expect(result2?.gasPrice).toBeDefined(); }); - it('should test getGasPriceEstimate with different scenarios', () => { - // Test with medium.suggestedMaxFeePerGas - instance.props = { - ...defaultTestProps, - gasFeeEstimates: { - medium: { suggestedMaxFeePerGas: '25' }, - }, - }; - let result = instance.getGasPriceEstimate(); - expect(result).toBeDefined(); - - // Test with medium value directly - instance.props = { - ...defaultTestProps, - gasFeeEstimates: { - medium: '20', - }, - }; - result = instance.getGasPriceEstimate(); - expect(result).toBeDefined(); - - // Test with gasPrice fallback - instance.props = { - ...defaultTestProps, - gasFeeEstimates: { - gasPrice: '15', - }, - }; - result = instance.getGasPriceEstimate(); - expect(result).toBeDefined(); - - // Test with no estimates (fallback to '0') - instance.props = { - ...defaultTestProps, - gasFeeEstimates: {}, - }; - result = instance.getGasPriceEstimate(); - expect(result).toBeDefined(); - }); - it('should test renderEmpty with switch network scenarios', () => { instance.context = { colors: mockTheme.colors, diff --git a/app/components/Views/DetectedTokensConfirmation/index.tsx b/app/components/Views/DetectedTokensConfirmation/index.tsx index 97ab215c948..91ea11e5273 100644 --- a/app/components/Views/DetectedTokensConfirmation/index.tsx +++ b/app/components/Views/DetectedTokensConfirmation/index.tsx @@ -1,5 +1,6 @@ import React, { useRef } from 'react'; import { StyleSheet, View, Text } from 'react-native'; +import { RouteProp, useRoute } from '@react-navigation/native'; import ReusableModal, { ReusableModalRef } from '../../UI/ReusableModal'; import { fontStyles } from '../../../styles/common'; import StyledButton from '../../UI/StyledButton'; @@ -53,16 +54,16 @@ const createStyles = (colors: any) => }, }); -interface Props { - route: { - params: { - isHidingAll?: boolean; - onConfirm: () => void; - }; - }; +interface DetectedTokensConfirmationRouteParams { + isHidingAll?: boolean; + onConfirm: () => void; } -const DetectedTokensConfirmation = ({ route }: Props) => { +const DetectedTokensConfirmation = () => { + const route = + useRoute< + RouteProp<{ params: DetectedTokensConfirmationRouteParams }, 'params'> + >(); const { onConfirm, isHidingAll } = route.params; const modalRef = useRef(null); const { colors } = useTheme(); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index 16f1e7254d0..982f2b34aba 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -1350,7 +1350,7 @@ describe('PerpsSection', () => { ); }); - it('passes isEmpty: false when trending markets are shown', () => { + it('passes isEmpty: true when trending markets are shown (no positions/orders = empty state)', () => { usePerpsMarkets.mockReturnValue({ markets: [makeTrendingMarket()], isLoading: false, @@ -1364,7 +1364,7 @@ describe('PerpsSection', () => { ); expect(mockUseHomeViewedEvent).toHaveBeenLastCalledWith( - expect.objectContaining({ isEmpty: false }), + expect.objectContaining({ isEmpty: true }), ); }); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx index 7bac10e0309..7d22860daa5 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx @@ -280,7 +280,7 @@ const PerpsSection = forwardRef( sectionName: HomeSectionNames.PERPS, sectionIndex, totalSectionsLoaded, - isEmpty: !hasItems && trendingMarkets.length === 0, + isEmpty: !hasItems, itemCount: hasItems ? displayPositions.length + displayOrders.length : 0, }); diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index c472dbaff2d..0b69c68cac9 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -3,6 +3,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import PredictionsSection from './PredictionsSection'; import Routes from '../../../../../constants/navigation/Routes'; +import { PREDICT_CLAIM_BUTTON_TEST_IDS } from '../../../../UI/Predict/components/PredictActionButtons/PredictClaimButton.testIds'; const mockNavigate = jest.fn(); const mockClaim = jest.fn(); @@ -35,7 +36,7 @@ jest.mock('../../../../UI/Predict/hooks/useUnrealizedPnL', () => ({ jest.mock('../../../../../selectors/preferencesController', () => ({ ...jest.requireActual('../../../../../selectors/preferencesController'), - selectPrivacyMode: () => false, + selectPrivacyMode: jest.fn(() => false), })); jest.mock('@tanstack/react-query', () => { @@ -80,6 +81,9 @@ const mockUsePredictMarketsForHomepage = jest.requireMock('./hooks').usePredictMarketsForHomepage; const mockUsePredictPositionsForHomepage = jest.requireMock('./hooks').usePredictPositionsForHomepage; +const mockSelectPrivacyMode = jest.requireMock( + '../../../../../selectors/preferencesController', +).selectPrivacyMode as jest.Mock; const mockActivePositions = [ { @@ -143,6 +147,7 @@ describe('PredictionsSection', () => { beforeEach(() => { jest.clearAllMocks(); mockClaim.mockResolvedValue(undefined); + mockSelectPrivacyMode.mockReturnValue(false); // Reset mock return value to default (true) to ensure test isolation jest @@ -479,6 +484,76 @@ describe('PredictionsSection', () => { }); }); + describe('privacy mode', () => { + beforeEach(() => { + mockSelectPrivacyMode.mockReturnValue(true); + }); + + it('hides monetary values on position rows', async () => { + mockUsePredictPositionsForHomepage.mockImplementation( + ({ + claimable = false, + }: { maxPositions?: number; claimable?: boolean } = {}) => ({ + positions: claimable ? [] : mockActivePositions, + isLoading: false, + error: null, + totalClaimableValue: 0, + refetch: jest.fn(), + }), + ); + + renderWithProvider( + , + ); + + await waitFor(() => { + expect(screen.getByText('Test Position 1')).toBeOnTheScreen(); + }); + + expect(screen.queryByText('$10 on Yes to win $15')).toBeNull(); + expect(screen.queryByText('$12')).toBeNull(); + expect(screen.queryByText('20%')).toBeNull(); + expect(screen.queryByText('-40%')).toBeNull(); + expect(screen.queryAllByText(/•+/).length).toBeGreaterThan(0); + }); + + it('masks claim amount and still invokes claim on press', async () => { + mockUsePredictPositionsForHomepage.mockImplementation( + ({ + claimable = false, + }: { maxPositions?: number; claimable?: boolean } = {}) => ({ + positions: claimable ? mockClaimablePositions : mockActivePositions, + isLoading: false, + error: null, + totalClaimableValue: claimable ? 200 : 0, + refetch: jest.fn(), + }), + ); + + renderWithProvider( + , + ); + + await waitFor(() => { + expect( + screen.getByTestId( + PREDICT_CLAIM_BUTTON_TEST_IDS.PREDICT_CLAIM_BUTTON, + ), + ).toBeOnTheScreen(); + }); + + expect(screen.queryByText('Claim $200.00')).toBeNull(); + + fireEvent.press( + screen.getByTestId(PREDICT_CLAIM_BUTTON_TEST_IDS.PREDICT_CLAIM_BUTTON), + ); + + await waitFor(() => { + expect(mockClaim).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('refresh functionality', () => { it('refreshes both positions and markets on pull-to-refresh', async () => { const mockRefetchPositions = jest.fn().mockResolvedValue(undefined); diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index ddea412a829..ef7aeab0caa 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -186,7 +186,7 @@ const PredictionsSection = forwardRef< const isEmpty = !isLoading && !hasPositions && markets.length === 0 && !hasError; - const itemCount = hasPositions ? positions.length : markets.length; + const itemCount = hasPositions ? positions.length : 0; // Determine whether the section will actually render visible content. // Pass null when the section returns null so the event fires immediately. @@ -201,8 +201,9 @@ const PredictionsSection = forwardRef< sectionName: HomeSectionNames.PREDICT, sectionIndex, totalSectionsLoaded, + // Empty when user has no positions (showing discovery/promotional content or nothing). // Treat error state as empty — there is no useful content to show. - isEmpty: isEmpty || !!hasError, + isEmpty: !hasPositions || !!hasError, itemCount, }); @@ -283,6 +284,7 @@ const PredictionsSection = forwardRef< key={`${position.outcomeId}:${position.outcomeIndex}`} position={position} onPress={handlePositionPress} + privacyMode={Boolean(privacyMode)} /> )) )} @@ -291,7 +293,7 @@ const PredictionsSection = forwardRef< totalClaimableValue > 0 && ( diff --git a/app/components/Views/Homepage/Sections/Predictions/components/PredictPositionRow.tsx b/app/components/Views/Homepage/Sections/Predictions/components/PredictPositionRow.tsx index 0532758b987..d48ae6e1754 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/PredictPositionRow.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/PredictPositionRow.tsx @@ -11,15 +11,24 @@ import { } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../../util/theme'; +import SensitiveText, { + SensitiveTextLength, +} from '../../../../../../component-library/components/Texts/SensitiveText'; +import { + TextVariant as ComponentTextVariant, + TextColor as ComponentTextColor, +} from '../../../../../../component-library/components/Texts/Text/Text.types'; import { formatPercentage, formatPrice, } from '../../../../../UI/Predict/utils/format'; import type { PredictPosition } from '../../../../../UI/Predict/types'; +import { strings } from '../../../../../../../locales/i18n'; interface PredictPositionRowProps { position: PredictPosition; onPress: (position: PredictPosition) => void; + privacyMode: boolean; } /** @@ -32,6 +41,7 @@ interface PredictPositionRowProps { export const PredictPositionRow = ({ position, onPress, + privacyMode, }: PredictPositionRowProps) => { const tw = useTailwind(); @@ -39,11 +49,14 @@ export const PredictPositionRow = ({ onPress(position); }, [onPress, position]); + const { title, outcome, initialValue, size, currentValue, percentPnl } = + position; + return ( - {position.title} + {title} - - {formatPrice(position.initialValue, { maximumDecimals: 2 })} on{' '} - {position.outcome} to win{' '} - {formatPrice(position.size, { maximumDecimals: 2 })} - + {strings('predict.position_info', { + initialValue: formatPrice(initialValue, { + maximumDecimals: 2, + }), + outcome, + shares: formatPrice(size, { + maximumDecimals: 2, + }), + })} + - - {formatPrice(position.currentValue, { maximumDecimals: 2 })} - - + {formatPrice(currentValue, { maximumDecimals: 2 })} + + = 0 - ? TextColor.SuccessDefault - : TextColor.ErrorDefault + percentPnl >= 0 + ? ComponentTextColor.Success + : ComponentTextColor.Error } + isHidden={privacyMode} + length={SensitiveTextLength.Short} > - {formatPercentage(position.percentPnl)} - + {formatPercentage(percentPnl)} + diff --git a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx index 02e51f1bb75..6180740affe 100644 --- a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx @@ -182,7 +182,7 @@ const TokensSection = forwardRef( sectionName: HomeSectionNames.TOKENS, sectionIndex, totalSectionsLoaded, - isEmpty: isZeroBalanceAccount, + isEmpty: isZeroBalanceAccount || showTokensError, itemCount, }); diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx index 43d359d0463..fee0cba3db7 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx @@ -35,13 +35,6 @@ jest.mock('../../confirmations/hooks/7702/useEIP7702Networks', () => ({ const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - goBack: mockGoBack, - }), -})); const mockAddress = '0x67B2fAf7959fB61eb9746571041476Bbd0672569'; const mockAccount = createMockInternalAccount( @@ -51,11 +44,24 @@ const mockAccount = createMockInternalAccount( EthAccountType.Eoa, ); +let mockRouteParams: { account: InternalAccount } = { + account: mockAccount, +}; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + useRoute: () => ({ + params: mockRouteParams, + }), +})); + const renderWithAccount = (account: InternalAccount | undefined) => { - const mockRoute = { - params: { - account: account || mockAccount, - }, + mockRouteParams = { + account: account || mockAccount, }; // Create proper state that includes the account in the AccountsController @@ -74,7 +80,7 @@ const renderWithAccount = (account: InternalAccount | undefined) => { return renderWithProvider( - + , { state: { @@ -106,6 +112,7 @@ const renderWithAccount = (account: InternalAccount | undefined) => { describe('AccountDetails', () => { beforeEach(() => { jest.clearAllMocks(); + mockRouteParams = { account: mockAccount }; }); it('displays account name and address when account is defined', () => { @@ -126,15 +133,13 @@ describe('AccountDetails', () => { EthAccountType.Eoa, ); - const mockRoute = { - params: { - account: nonExistentAccount, - }, + mockRouteParams = { + account: nonExistentAccount, }; renderWithProvider( - + , { state: { diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.tsx index c15c3d02501..aacd79d70f3 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.tsx @@ -7,25 +7,23 @@ import { getMemoizedInternalAccountByAddress } from '../../../../selectors/accou import { useSelector } from 'react-redux'; import { RootState } from '../../../../reducers'; import Routes from '../../../../constants/navigation/Routes'; -import { useNavigation } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import PrivateKeyAccountDetails from './AccountTypes/PrivateKeyAccountDetails'; import HardwareAccountDetails from './AccountTypes/HardwareAccountDetails'; import { isHardwareAccount } from '../../../../util/address'; import SnapAccountDetails from './AccountTypes/SnapAccountDetails'; -interface AccountDetailsProps { - route: { - params: { - account: InternalAccount; - }; - }; +interface AccountDetailsRouteParams { + account: InternalAccount; } -export const AccountDetails = (props: AccountDetailsProps) => { +export const AccountDetails = () => { + const route = + useRoute>(); const navigation = useNavigation(); const { account: { address }, - } = props.route.params; + } = route.params; const account: InternalAccount | undefined = useSelector((state: RootState) => getMemoizedInternalAccountByAddress(state, address), ); diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx index c234546ea93..e9b445d4acd 100644 --- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx +++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx @@ -24,6 +24,28 @@ import { KeyringTypes } from '@metamask/keyring-controller'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); +const mockAccountGroup = createMockAccountGroup( + 'keyring:test-wallet/0', + 'Test Account Group', + ['account-1'], +); +const mockAccount = createMockInternalAccount( + 'account-1', + '0x1234567890123456789012345678901234567890', + 'Test Account', +); +mockAccount.options.entropySource = 'keyring:test-wallet'; +const groups = [mockAccountGroup]; +const mockWallet = createMockWallet( + 'keyring:test-wallet', + 'Test Wallet', + groups, +); +const internalAccounts = createMockInternalAccountsFromGroups(groups); +const baseState = createMockState([mockWallet], internalAccounts); + +let mockRouteParams = { accountGroup: mockAccountGroup }; + jest.mock('../../../../selectors/multichainAccounts/accounts', () => { const actual = jest.requireActual( '../../../../selectors/multichainAccounts/accounts', @@ -42,6 +64,9 @@ jest.mock('@react-navigation/native', () => ({ goBack: mockGoBack, navigate: mockNavigate, }), + useRoute: () => ({ + params: mockRouteParams, + }), })); jest.mock('../../../../util/address', () => ({ @@ -51,26 +76,6 @@ jest.mock('../../../../util/address', () => ({ areAddressesEqual: jest.fn((addr1, addr2) => addr1 === addr2), })); -const mockAccountGroup = createMockAccountGroup( - 'keyring:test-wallet/0', - 'Test Account Group', - ['account-1'], -); -const mockAccount = createMockInternalAccount( - 'account-1', - '0x1234567890123456789012345678901234567890', - 'Test Account', -); -mockAccount.options.entropySource = 'keyring:test-wallet'; -const groups = [mockAccountGroup]; -const mockWallet = createMockWallet( - 'keyring:test-wallet', - 'Test Wallet', - groups, -); -const internalAccounts = createMockInternalAccountsFromGroups(groups); -const baseState = createMockState([mockWallet], internalAccounts); - const mockNetworkControllerState = { networkConfigurationsByChainId: { 0x1: { @@ -115,18 +120,11 @@ const mockMultichainNetworkController = { describe('AccountGroupDetails', () => { beforeEach(() => { jest.clearAllMocks(); + mockRouteParams = { accountGroup: mockAccountGroup }; (isHDOrFirstPartySnapAccount as jest.Mock).mockReturnValue(true); (isHardwareAccount as jest.Mock).mockReturnValue(false); }); - const defaultProps = { - route: { - params: { - accountGroup: mockAccountGroup, - }, - }, - }; - const mockState = { ...baseState, settings: { @@ -146,10 +144,9 @@ describe('AccountGroupDetails', () => { }; it('renders correctly with account group details', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); expect( getByTestId(AccountDetailsIds.ACCOUNT_DETAILS_CONTAINER), @@ -161,10 +158,9 @@ describe('AccountGroupDetails', () => { }); it('navigates back when back button is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); const backButton = getByTestId(AccountDetailsIds.BACK_BUTTON); fireEvent.press(backButton); @@ -186,10 +182,9 @@ describe('AccountGroupDetails', () => { }, ); - const { unmount } = renderWithProvider( - , - { state: mockState }, - ); + const { unmount } = renderWithProvider(, { + state: mockState, + }); // Multiple subscriptions may exist. Invoke handlers until we find the one that triggers goBack. let foundHandlerCalledGoBack = false; @@ -219,28 +214,25 @@ describe('AccountGroupDetails', () => { }); it('displays unlock to reveal text for private keys', () => { - const { getByText } = renderWithProvider( - , - { state: mockState }, - ); + const { getByText } = renderWithProvider(, { + state: mockState, + }); expect(getByText('Unlock to reveal')).toBeTruthy(); }); it('displays set up text for smart account', () => { - const { getByText } = renderWithProvider( - , - { state: mockState }, - ); + const { getByText } = renderWithProvider(, { + state: mockState, + }); expect(getByText('Set up')).toBeTruthy(); }); it('renders Wallet component when wallet exists', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); expect(getByTestId(AccountDetailsIds.WALLET_NAME_LINK)).toBeTruthy(); }); @@ -249,7 +241,7 @@ describe('AccountGroupDetails', () => { (isHDOrFirstPartySnapAccount as jest.Mock).mockReturnValue(false); const { queryByText, queryByTestId } = renderWithProvider( - , + , { state: mockState }, ); @@ -265,13 +257,11 @@ describe('AccountGroupDetails', () => { 'Test Account Group', ); - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + mockRouteParams = { accountGroup: singleAccountGroup }; + + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); expect( getByTestId(AccountDetailsIds.ACCOUNT_DETAILS_CONTAINER), @@ -299,11 +289,10 @@ describe('AccountGroupDetails', () => { multiAccountInternalAccounts, ); + mockRouteParams = { accountGroup: multiAccountGroup }; + const { queryByText, queryByTestId } = renderWithProvider( - , + , { state: multiAccountState }, ); @@ -334,11 +323,11 @@ describe('AccountGroupDetails', () => { }; const { getByTestId: getByTestId1 } = renderWithProvider( - , + , { state: stateWithoutWallet }, ); const { getByTestId: getByTestId2 } = renderWithProvider( - , + , { state: stateWithoutAccount }, ); @@ -351,10 +340,9 @@ describe('AccountGroupDetails', () => { }); it('navigates to Address List when Networks link is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); const networksLink = getByTestId(AccountDetailsIds.NETWORKS_LINK); fireEvent.press(networksLink); @@ -367,10 +355,9 @@ describe('AccountGroupDetails', () => { }); it('navigates to Smart Account Details when Smart Account link is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); const smartAccountLink = getByTestId(AccountDetailsIds.SMART_ACCOUNT_LINK); fireEvent.press(smartAccountLink); @@ -381,10 +368,9 @@ describe('AccountGroupDetails', () => { }); it('navigates to edit account name when account name is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); const accountNameLink = getByTestId(AccountDetailsIds.ACCOUNT_NAME_LINK); fireEvent.press(accountNameLink); @@ -394,12 +380,9 @@ describe('AccountGroupDetails', () => { }); it('uses the group icon seed address to render the avatar', () => { - const { getByTestId } = renderWithProvider( - , - { - state: mockState, - }, - ); + const { getByTestId } = renderWithProvider(, { + state: mockState, + }); // Assert that the selector selectIconSeedAddressByAccountGroupId was called const { selectIconSeedAddressByAccountGroupId: mockedFactory } = @@ -445,13 +428,11 @@ describe('AccountGroupDetails', () => { }, }; - const { queryByTestId } = renderWithProvider( - , - { state: ledgerState }, - ); + mockRouteParams = { accountGroup: mockLedgerAccountGroup }; + + const { queryByTestId } = renderWithProvider(, { + state: ledgerState, + }); // Verify that the private key button is NOT visible for hardware wallet accounts expect(queryByTestId(AccountDetailsIds.PRIVATE_KEYS_LINK)).toBeNull(); @@ -492,13 +473,11 @@ describe('AccountGroupDetails', () => { }, }; - const { getByTestId } = renderWithProvider( - , - { state: hdState }, - ); + mockRouteParams = { accountGroup: mockHDAccountGroup }; + + const { getByTestId } = renderWithProvider(, { + state: hdState, + }); // Verify that the private key button IS visible for non-hardware wallet accounts expect(getByTestId(AccountDetailsIds.PRIVATE_KEYS_LINK)).toBeTruthy(); diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx index 1423678ff06..4d35c690d63 100644 --- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx +++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx @@ -11,7 +11,7 @@ import Text, { TextVariant, } from '../../../../component-library/components/Texts/Text'; import ButtonLink from '../../../../component-library/components/Buttons/Button/variants/ButtonLink'; -import { useNavigation } from '@react-navigation/native'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { AccountGroupObject } from '@metamask/account-tree-controller'; import { AlignItems, @@ -60,17 +60,15 @@ import { import Routes from '../../../../constants/navigation/Routes'; import { selectAvatarAccountType } from '../../../../selectors/settings'; -interface AccountGroupDetailsProps { - route: { - params: { - accountGroup: AccountGroupObject; - }; - }; +interface AccountGroupDetailsRouteParams { + accountGroup: AccountGroupObject; } -export const AccountGroupDetails = (props: AccountGroupDetailsProps) => { +export const AccountGroupDetails = () => { + const route = + useRoute>(); const navigation = useNavigation(); - const { accountGroup: initialAccountGroup } = props.route.params; + const { accountGroup: initialAccountGroup } = route.params; const { id } = initialAccountGroup; // Use selector to get current account group data from Redux store diff --git a/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.test.tsx b/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.test.tsx new file mode 100644 index 00000000000..a4570a37157 --- /dev/null +++ b/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { WalletDetails } from './WalletDetails'; +import { selectWalletById } from '../../../../selectors/multichainAccounts/accountTreeController'; +import { AccountWalletObject } from '@metamask/account-tree-controller'; + +let mockRouteParams: { walletId: string }; + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useRoute: () => ({ + params: mockRouteParams, + }), + }; +}); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock( + '../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + selectWalletById: jest.fn(), + }), +); + +const mockBaseWalletDetails = jest.fn( + (_props?: Record) => null, +); +jest.mock('./BaseWalletDetails', () => { + const { View, Text } = jest.requireActual('react-native'); + return { + BaseWalletDetails: (props: Record) => { + mockBaseWalletDetails(props); + return ( + + {`BaseWalletDetails:${(props.wallet as AccountWalletObject).metadata.name}`} + + ); + }, + }; +}); + +const mockSelectWalletById = selectWalletById as jest.MockedFunction< + typeof selectWalletById +>; + +const createWallet = (id: string, name: string): AccountWalletObject => + ({ + id, + metadata: { name }, + groups: {}, + }) as unknown as AccountWalletObject; + +describe('WalletDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRouteParams = { walletId: 'wallet-1' }; + }); + + it('renders BaseWalletDetails when wallet is found', () => { + const wallet = createWallet('wallet-1', 'My Wallet'); + mockSelectWalletById.mockReturnValue(((wId: string) => + wId === 'wallet-1' ? wallet : null) as ReturnType< + typeof selectWalletById + >); + + const { getByText } = render(); + + expect(getByText('BaseWalletDetails:My Wallet')).toBeTruthy(); + expect(mockBaseWalletDetails).toHaveBeenCalledWith( + expect.objectContaining({ wallet }), + ); + }); + + it('returns null when wallet is not found', () => { + mockSelectWalletById.mockReturnValue( + (() => null) as ReturnType, + ); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + expect(mockBaseWalletDetails).not.toHaveBeenCalled(); + }); + + it('passes the correct walletId from route params to the selector', () => { + mockRouteParams = { walletId: 'wallet-42' }; + const wallet = createWallet('wallet-42', 'Wallet 42'); + const lookupFn = jest.fn((wId: string) => + wId === 'wallet-42' ? wallet : null, + ); + mockSelectWalletById.mockReturnValue( + lookupFn as unknown as ReturnType, + ); + + render(); + + expect(lookupFn).toHaveBeenCalledWith('wallet-42'); + }); + + it('returns null when selectWallet returns undefined', () => { + mockSelectWalletById.mockReturnValue( + (() => undefined) as unknown as ReturnType, + ); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + expect(mockBaseWalletDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.tsx b/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.tsx index 12f34b1fc0b..8b9c67b0166 100644 --- a/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.tsx +++ b/app/components/Views/MultichainAccounts/WalletDetails/WalletDetails.tsx @@ -1,19 +1,18 @@ import React from 'react'; +import { RouteProp, useRoute } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { BaseWalletDetails } from './BaseWalletDetails'; import { selectWalletById } from '../../../../selectors/multichainAccounts/accountTreeController'; import { AccountWalletId } from '@metamask/account-api'; -interface WalletDetailsProps { - route: { - params: { - walletId: AccountWalletId; - }; - }; +interface WalletDetailsRouteParams { + walletId: AccountWalletId; } -export const WalletDetails = (props: WalletDetailsProps) => { - const { walletId } = props.route.params; +export const WalletDetails = () => { + const route = + useRoute>(); + const { walletId } = route.params; const selectWallet = useSelector(selectWalletById); const wallet = selectWallet?.(walletId); diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts index 7f12c0efd53..f0ad8f03e46 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts @@ -43,6 +43,7 @@ jest.mock('../../../util/number', () => ({ jest.mock('../../../util/transaction-controller', () => ({ speedUpTransaction: jest.fn(), + getPreviousGasFromController: jest.fn(() => undefined), })); jest.mock('../../../util/transactions', () => ({ @@ -83,6 +84,7 @@ jest.mock('../../../core/Engine', () => ({ context: { TransactionController: { stopTransaction: jest.fn(), + getTransactions: jest.fn(() => []), }, ApprovalController: { acceptRequest: jest.fn(), @@ -97,7 +99,10 @@ jest.mock('../../../core/Engine', () => ({ import { decGWEIToHexWEI } from '../../../util/conversions'; import { addHexPrefix } from '../../../util/number'; -import { speedUpTransaction as speedUpTx } from '../../../util/transaction-controller'; +import { + speedUpTransaction as speedUpTx, + getPreviousGasFromController, +} from '../../../util/transaction-controller'; import { validateTransactionActionBalance } from '../../../util/transactions'; import { isHardwareAccount } from '../../../util/address'; import { getDeviceId } from '../../../core/Ledger/Ledger'; @@ -108,7 +113,10 @@ describe('useUnifiedTxActions', () => { >; interface EngineContextMock { - TransactionController: { stopTransaction: jest.Mock }; + TransactionController: { + stopTransaction: jest.Mock; + getTransactions: jest.Mock; + }; ApprovalController: { acceptRequest: jest.Mock; rejectRequest: jest.Mock }; } @@ -117,6 +125,7 @@ describe('useUnifiedTxActions', () => { beforeEach(() => { jest.resetAllMocks(); + engineContext.TransactionController.getTransactions = jest.fn(() => []); (createQRSigningTransactionModalNavDetails as jest.Mock).mockReturnValue([ 'QRSigningModal', @@ -360,6 +369,31 @@ describe('useUnifiedTxActions', () => { // Restore default selector for subsequent tests mockUseSelector.mockImplementation(defaultSelectorImpl); }); + + it('clamps EIP-1559 priority fee up to previousGas × rate when below minimum', async () => { + const { result } = renderHook(() => useUnifiedTxActions()); + const tx = { id: 'clamp-speedup' } as unknown as TransactionMeta; + + (getPreviousGasFromController as jest.Mock).mockImplementation( + (txId: string) => + txId === 'clamp-speedup' + ? { maxFeePerGas: '0x64', maxPriorityFeePerGas: '0x64' } + : undefined, + ); + + act(() => result.current.onSpeedUpAction(true, tx)); + await act(async () => { + await result.current.speedUpTransaction({ + maxFeePerGas: '0x3e8', // 1000 (above min 110) + maxPriorityFeePerGas: '0x5', // 5 (below min 110) + }); + }); + + expect(speedUpTx).toHaveBeenCalledWith('clamp-speedup', { + maxFeePerGas: '0x3e8', + maxPriorityFeePerGas: '0x6e', // ceil(100 * 1.1) = 110 = 0x6e + }); + }); }); describe('cancelTransaction', () => { @@ -422,6 +456,33 @@ describe('useUnifiedTxActions', () => { expect(result.current.cancelTxId).toBe('11'); expect(result.current.existingTx).toBe(tx); }); + + it('clamps EIP-1559 priority fee up to previousGas × rate when below minimum', async () => { + const { result } = renderHook(() => useUnifiedTxActions()); + const tx = { id: 'clamp-cancel' } as unknown as TransactionMeta; + + (getPreviousGasFromController as jest.Mock).mockImplementation( + (txId: string) => + txId === 'clamp-cancel' + ? { maxFeePerGas: '0x64', maxPriorityFeePerGas: '0x64' } + : undefined, + ); + + act(() => result.current.onCancelAction(true, tx)); + await act(async () => { + await result.current.cancelTransaction({ + maxFeePerGas: '0x3e8', // 1000 (above min 110) + maxPriorityFeePerGas: '0x5', // 5 (below min 110) + }); + }); + + expect( + engineContext.TransactionController.stopTransaction, + ).toHaveBeenCalledWith('clamp-cancel', { + maxFeePerGas: '0x3e8', + maxPriorityFeePerGas: '0x6e', // ceil(100 * 1.1) = 110 + }); + }); }); describe('QR flow helpers', () => { diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts index 459ebf99604..0481e35d937 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts @@ -16,8 +16,14 @@ import { selectAccounts } from '../../../selectors/accountTrackerController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import { selectGasFeeEstimates } from '../../../selectors/confirmTransaction'; import { isHardwareAccount } from '../../../util/address'; -import { getMediumGasPriceHex } from '../../../util/confirmation/gas'; -import { speedUpTransaction as speedUpTx } from '../../../util/transaction-controller'; +import { + getGasValuesForReplacement, + getMediumGasPriceHex, +} from '../../../util/confirmation/gas'; +import { + getPreviousGasFromController, + speedUpTransaction as speedUpTx, +} from '../../../util/transaction-controller'; import { validateTransactionActionBalance } from '../../../util/transactions'; import { createLedgerTransactionModalNavDetails, @@ -212,7 +218,12 @@ export function useUnifiedTxActions() { throw new Error('Missing transaction id for speed up'); } - const gasValues = getParamsToSend(params); + const rawGasValues = getParamsToSend(params); + const gasValues = getGasValuesForReplacement( + rawGasValues, + getPreviousGasFromController(speedUpTxId), + SPEED_UP_RATE, + ); if (isLedgerAccount) { const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; @@ -247,7 +258,12 @@ export function useUnifiedTxActions() { throw new Error('Missing transaction id for cancel'); } - const gasValues = getParamsToSend(params); + const rawGasValues = getParamsToSend(params); + const gasValues = getGasValuesForReplacement( + rawGasValues, + getPreviousGasFromController(cancelTxId), + CANCEL_RATE, + ); if (isLedgerAccount) { const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; diff --git a/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.test.tsx b/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.test.tsx index 0e3c4e088d0..0ef0b2b7d8b 100644 --- a/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.test.tsx @@ -65,16 +65,25 @@ const defaultGasValues = { networkFeeNative: '0.001', networkFeeFiat: '$1.80', nativeTokenSymbol: 'ETH', + isInitialGasReady: true, }; jest.mock('../../../hooks/gas/useCancelSpeedupGas', () => ({ useCancelSpeedupGas: jest.fn(), - getBumpParamsForCancelSpeedup: jest.fn(() => ({})), + getBumpParamsForCancelSpeedup: jest.fn(() => ({ + gasValues: { maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x1' }, + userFeeLevel: 'medium', + })), })); jest.mock('../../../../../../util/transaction-controller', () => ({ ...jest.requireActual('../../../../../../util/transaction-controller'), updateTransactionGasFees: jest.fn(), + updatePreviousGasParams: jest.fn(), +})); + +jest.mock('../../../hooks/gas/useGasFeeEstimates', () => ({ + useGasFeeEstimates: jest.fn(() => ({ gasFeeEstimates: {} })), })); jest.mock('../../../context/gas-fee-modal-transaction', () => ({ @@ -259,4 +268,41 @@ describe('CancelSpeedupModal', () => { expect(queryByTestId('gas-fee-modal')).toBeNull(); }); }); + + it('does not call onConfirm when isInitialGasReady is false', () => { + mockedUseCancelSpeedupGas.mockReturnValue({ + ...defaultGasValues, + isInitialGasReady: false, + }); + + const { getByText } = renderWithProvider( + , + { state: baseState }, + ); + + fireEvent.press(getByText('Confirm')); + + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('calls onConfirm when isInitialGasReady is true', async () => { + mockedUseCancelSpeedupGas.mockReturnValue({ + ...defaultGasValues, + isInitialGasReady: true, + }); + + const { getByText } = renderWithProvider( + , + { state: baseState }, + ); + + fireEvent.press(getByText('Confirm')); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith({ + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }); + }); + }); }); diff --git a/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.tsx b/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.tsx index b7bdde05847..8e04f5f9817 100644 --- a/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.tsx +++ b/app/components/Views/confirmations/components/modals/cancel-speedup-modal/cancel-speedup-modal.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Pressable, ScrollView, StyleSheet } from 'react-native'; import Modal from 'react-native-modal'; -import type { - FeeMarketEIP1559Values, - GasPriceValue, - TransactionMeta, +import { + isEIP1559Transaction, + type FeeMarketEIP1559Values, + type GasPriceValue, + type TransactionMeta, } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -39,8 +40,11 @@ import { getBumpParamsForCancelSpeedup, useCancelSpeedupGas, } from '../../../hooks/gas/useCancelSpeedupGas'; -import { selectGasFeeEstimates } from '../../../../../../selectors/confirmTransaction'; -import { updateTransactionGasFees } from '../../../../../../util/transaction-controller'; +import { + updatePreviousGasParams, + updateTransactionGasFees, +} from '../../../../../../util/transaction-controller'; +import { useGasFeeEstimates } from '../../../hooks/gas/useGasFeeEstimates'; import { GasFeeModal } from '../gas-fee-modal'; import { GasSpeed } from '../../gas/gas-speed'; import NetworkAssetLogo from '../../../../../UI/NetworkAssetLogo'; @@ -48,8 +52,6 @@ import InfoSection from '../../UI/info-row/info-section'; import InfoRow from '../../UI/info-row/info-row'; import styleSheet from './cancel-speedup-modal.styles'; import { useStyles } from '../../../../../hooks/useStyles'; -import { useSelector } from 'react-redux'; -import type { RootState } from '../../../../../../reducers'; const NetworkFeeRow = ({ fiat, @@ -171,27 +173,46 @@ export function CancelSpeedupModal({ const { colors } = useTheme(); const [gasModalVisible, setGasModalVisible] = useState(false); - const gasFeeEstimates = useSelector((state: RootState) => - selectGasFeeEstimates(state), - ); - + const { gasFeeEstimates } = useGasFeeEstimates(tx?.networkClientId); const { paramsForController, networkFeeNative, networkFeeFiat, nativeTokenSymbol, + isInitialGasReady, } = useCancelSpeedupGas({ txId: tx?.id }); // Seed the transaction with bump params when cancel/speed up modal opens so the gas modal shows suggested values. + // Stores the original gas as previousGas first (prevents re-seeding on subsequent renders). useEffect(() => { - if (!isVisible || !tx?.id || !tx) return; - const bumpParams = getBumpParamsForCancelSpeedup( + if (!isVisible || !tx?.id) return; + if (tx.previousGas) return; + + const { txParams } = tx; + if (txParams) { + if (isEIP1559Transaction(txParams)) { + updatePreviousGasParams(tx.id, { + maxFeePerGas: txParams.maxFeePerGas as string, + maxPriorityFeePerGas: txParams.maxPriorityFeePerGas as string, + gasLimit: (txParams.gasLimit ?? txParams.gas) as string, + }); + } else { + updatePreviousGasParams(tx.id, { + gasLimit: (txParams.gasLimit ?? txParams.gas) as string, + }); + } + } + + const bumpResult = getBumpParamsForCancelSpeedup( tx, isCancel, gasFeeEstimates, ); - if (bumpParams) { - updateTransactionGasFees(tx.id, bumpParams); + if (bumpResult) { + updateTransactionGasFees(tx.id, { + ...bumpResult.gasValues, + userFeeLevel: bumpResult.userFeeLevel, + }); } }, [isVisible, tx?.id, isCancel, gasFeeEstimates, tx]); @@ -212,10 +233,12 @@ export function CancelSpeedupModal({ }); }, [onClose]); + const effectiveConfirmDisabled = confirmDisabled || !isInitialGasReady; + const handleConfirm = useCallback(() => { - if (confirmDisabled) return; + if (effectiveConfirmDisabled) return; onConfirm(paramsForController); - }, [onConfirm, paramsForController, confirmDisabled]); + }, [onConfirm, paramsForController, effectiveConfirmDisabled]); const title = isCancel ? strings('transaction.cancel_speedup_cancel_title') @@ -232,7 +255,7 @@ export function CancelSpeedupModal({ label: strings('transaction.confirm'), size: ButtonSize.Lg, onPress: handleConfirm, - isDisabled: confirmDisabled, + isDisabled: effectiveConfirmDisabled, }, ]; diff --git a/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.test.tsx b/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.test.tsx index 5a67aed5f3a..3f6c51548fb 100644 --- a/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.test.tsx @@ -36,22 +36,23 @@ const MOCK_NETWORK = { } as unknown as EIP7702NetworkConfiguration; const mockGoBack = jest.fn(); + +let mockRouteAddress: string | undefined = + '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'; + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: jest.fn(), goBack: mockGoBack, }), + useRoute: () => ({ + key: 'ConfirmationSwitchAccountType', + name: 'ConfirmationSwitchAccountType', + params: { address: mockRouteAddress }, + }), })); -const createMockRoute = ( - address = '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', -) => ({ - params: { address: address as `0x${string}` }, - key: 'ConfirmationSwitchAccountType', - name: 'ConfirmationSwitchAccountType' as const, -}); - jest.mock('react-native-safe-area-context', () => { const inset = { top: 0, right: 0, bottom: 0, left: 0 }; const frame = { width: 0, height: 0, x: 0, y: 0 }; @@ -99,6 +100,7 @@ const MOCK_NETWORK_MAINNET = { describe('Switch Account Type Modal', () => { beforeEach(() => { jest.clearAllMocks(); + mockRouteAddress = '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'; }); describe('rendering', () => { @@ -109,10 +111,9 @@ describe('Switch Account Type Modal', () => { networkSupporting7702Present: true, }); - const { getByText } = renderWithProvider( - , - { state: MOCK_STATE }, - ); + const { getByText } = renderWithProvider(, { + state: MOCK_STATE, + }); expect(getByText('Account 1')).toBeOnTheScreen(); expect(getByText('Sepolia')).toBeOnTheScreen(); }); @@ -125,7 +126,7 @@ describe('Switch Account Type Modal', () => { }); const { getByText, queryByText } = renderWithProvider( - , + , { state: MOCK_STATE }, ); @@ -143,10 +144,9 @@ describe('Switch Account Type Modal', () => { networkSupporting7702Present: true, }); - const { getByText } = renderWithProvider( - , - { state: MOCK_STATE }, - ); + const { getByText } = renderWithProvider(, { + state: MOCK_STATE, + }); expect(getByText('Account 1')).toBeOnTheScreen(); expect(getByText('Sepolia')).toBeOnTheScreen(); @@ -161,7 +161,7 @@ describe('Switch Account Type Modal', () => { }); const { getByTestId, queryByText } = renderWithProvider( - , + , { state: MOCK_STATE }, ); @@ -181,52 +181,40 @@ describe('Switch Account Type Modal', () => { networkSupporting7702Present: true, }); - const { getByText } = renderWithProvider( - , - { state: MOCK_STATE }, - ); + const { getByText } = renderWithProvider(, { + state: MOCK_STATE, + }); // Should render the account that matches the address expect(getByText('Account 1')).toBeOnTheScreen(); }); it('does not display account name when address does not match any account', () => { - const unknownAddress = '0x0000000000000000000000000000000000000001'; + mockRouteAddress = '0x0000000000000000000000000000000000000001'; jest.spyOn(Networks7702, 'useEIP7702Networks').mockReturnValue({ pending: false, network7702List: [MOCK_NETWORK], networkSupporting7702Present: true, }); - const { queryByText } = renderWithProvider( - , - { state: MOCK_STATE }, - ); + const { queryByText } = renderWithProvider(, { + state: MOCK_STATE, + }); // Account name should not be displayed since address doesn't match any account expect(queryByText('Account 1')).toBeNull(); }); it('displays fallback when no address is available from route or selected account', () => { + mockRouteAddress = undefined; jest.spyOn(Networks7702, 'useEIP7702Networks').mockReturnValue({ pending: false, network7702List: [], networkSupporting7702Present: false, }); - // Create route with undefined address param - const routeWithNoAddress = { - params: { address: undefined }, - key: 'ConfirmationSwitchAccountType', - name: 'ConfirmationSwitchAccountType' as const, - }; - const { getByTestId, getByText, queryByTestId } = renderWithProvider( - - } - />, + , { state: MOCK_STATE }, ); @@ -246,10 +234,9 @@ describe('Switch Account Type Modal', () => { networkSupporting7702Present: true, }); - const { getByTestId } = renderWithProvider( - , - { state: MOCK_STATE }, - ); + const { getByTestId } = renderWithProvider(, { + state: MOCK_STATE, + }); fireEvent.press(getByTestId('switch-account-goback')); @@ -263,10 +250,9 @@ describe('Switch Account Type Modal', () => { networkSupporting7702Present: true, }); - const { getByTestId } = renderWithProvider( - , - { state: MOCK_STATE }, - ); + const { getByTestId } = renderWithProvider(, { + state: MOCK_STATE, + }); const backButton = getByTestId('switch-account-goback'); fireEvent.press(backButton); @@ -286,13 +272,9 @@ describe('Switch Account Type Modal', () => { networkSupporting7702Present: true, }); - const testAddress = '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'; - renderWithProvider( - , - { state: MOCK_STATE }, - ); + renderWithProvider(, { state: MOCK_STATE }); - expect(mockUseEIP7702Networks).toHaveBeenCalledWith(testAddress); + expect(mockUseEIP7702Networks).toHaveBeenCalledWith(mockRouteAddress); }); }); }); diff --git a/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.tsx b/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.tsx index 9d1da28f3aa..e7c1ea2ec95 100644 --- a/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.tsx +++ b/app/components/Views/confirmations/components/modals/switch-account-type-modal/switch-account-type-modal.tsx @@ -2,7 +2,7 @@ import React, { useCallback } from 'react'; import { Hex } from '@metamask/utils'; import { TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; -import { useNavigation, RouteProp } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import Avatar, { AvatarSize, @@ -34,14 +34,14 @@ interface SwitchAccountTypeModalParamList { [key: string]: object | undefined; } -interface SwitchAccountTypeModalProps { - route: RouteProp< - SwitchAccountTypeModalParamList, - 'ConfirmationSwitchAccountType' - >; -} - -const SwitchAccountTypeModal = ({ route }: SwitchAccountTypeModalProps) => { +const SwitchAccountTypeModal = () => { + const route = + useRoute< + RouteProp< + SwitchAccountTypeModalParamList, + 'ConfirmationSwitchAccountType' + > + >(); const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); const selectedAccountAddress = diff --git a/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/types.ts b/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/types.ts index be42ecaba00..f47e4199108 100644 --- a/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/types.ts +++ b/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/types.ts @@ -17,6 +17,8 @@ export interface UseCancelSpeedupGasResult { networkFeeFiat: string | null; /** Native currency symbol for the chain (e.g. "ETH") */ nativeTokenSymbol: string; + /** True once previousGas has been persisted and initial gas params applied. */ + isInitialGasReady: boolean; } export interface UseCancelSpeedupGasInput { diff --git a/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.test.ts b/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.test.ts index 55eef5edde6..42687c0f5ba 100644 --- a/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.test.ts @@ -1,15 +1,19 @@ -import type { - FeeMarketEIP1559Values, - GasPriceValue, - TransactionMeta, +import { + GasFeeEstimateLevel, + UserFeeLevel, + type FeeMarketEIP1559Values, + type GasPriceValue, + type TransactionMeta, } from '@metamask/transaction-controller'; import { getBumpParamsForCancelSpeedup, useCancelSpeedupGas, + type BumpParamsResult, } from './useCancelSpeedupGas'; import { renderHookWithProvider } from '../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; import { useFeeCalculations } from '../useFeeCalculations'; +import type { GasFeeEstimatesInput } from '../../../../../../util/confirmation/gas'; const providerState = { state: { engine: { backgroundState } } } as const; @@ -123,6 +127,7 @@ describe('useCancelSpeedupGas', () => { expect(result.current.networkFeeNative).toBe('0'); expect(result.current.networkFeeFiat).toBeNull(); expect(result.current.nativeTokenSymbol).toBe('ETH'); + expect(result.current.isInitialGasReady).toBe(false); }); it('returns empty result when tx has no txParams', () => { @@ -318,6 +323,30 @@ describe('useCancelSpeedupGas', () => { ); expect(result.current.networkFeeDisplay).toContain('ETH'); }); + + it('returns isInitialGasReady=false when previousGas is not set', () => { + const { result } = renderHookWithProvider( + () => useCancelSpeedupGas({ txId: 'tx-1' }), + buildStateWithTransaction(mockTxEip1559), + ); + expect(result.current.isInitialGasReady).toBe(false); + }); + + it('returns isInitialGasReady=true when previousGas is set', () => { + const txWithPreviousGas = { + ...mockTxEip1559, + previousGas: { + maxFeePerGas: '0x174876e800', + maxPriorityFeePerGas: '0x59682f00', + gasLimit: '0x5208', + }, + } as unknown as TransactionMeta; + const { result } = renderHookWithProvider( + () => useCancelSpeedupGas({ txId: 'tx-1' }), + buildStateWithTransaction(txWithPreviousGas), + ); + expect(result.current.isInitialGasReady).toBe(true); + }); }); describe('getBumpParamsForCancelSpeedup', () => { @@ -332,22 +361,26 @@ describe('getBumpParamsForCancelSpeedup', () => { } as unknown as TransactionMeta; it('returns EIP-1559 params for speed up', () => { - const params = getBumpParamsForCancelSpeedup(eip1559Tx, false); - expect(params).toBeDefined(); - expect((params as FeeMarketEIP1559Values).maxFeePerGas).toBeDefined(); + const result = getBumpParamsForCancelSpeedup(eip1559Tx, false); + expect(result).toBeDefined(); + const { gasValues, userFeeLevel } = result as BumpParamsResult; + expect((gasValues as FeeMarketEIP1559Values).maxFeePerGas).toBeDefined(); expect( - (params as FeeMarketEIP1559Values).maxPriorityFeePerGas, + (gasValues as FeeMarketEIP1559Values).maxPriorityFeePerGas, ).toBeDefined(); + expect(userFeeLevel).toBe(UserFeeLevel.CUSTOM); }); it('returns EIP-1559 params for cancel with same or higher values than speed up', () => { - const speedUpParams = getBumpParamsForCancelSpeedup(eip1559Tx, false); - const cancelParams = getBumpParamsForCancelSpeedup(eip1559Tx, true); - expect(speedUpParams).toBeDefined(); - expect(cancelParams).toBeDefined(); - - const speedUp = speedUpParams as FeeMarketEIP1559Values; - const cancel = cancelParams as FeeMarketEIP1559Values; + const speedUpResult = getBumpParamsForCancelSpeedup(eip1559Tx, false); + const cancelResult = getBumpParamsForCancelSpeedup(eip1559Tx, true); + expect(speedUpResult).toBeDefined(); + expect(cancelResult).toBeDefined(); + + const speedUp = (speedUpResult as BumpParamsResult) + .gasValues as FeeMarketEIP1559Values; + const cancel = (cancelResult as BumpParamsResult) + .gasValues as FeeMarketEIP1559Values; expect(parseInt(cancel.maxFeePerGas ?? '0', 16)).toBeGreaterThanOrEqual( parseInt(speedUp.maxFeePerGas ?? '0', 16), ); @@ -362,9 +395,11 @@ describe('getBumpParamsForCancelSpeedup', () => { chainId: '0x1', txParams: { gas: '0x5208', gasPrice: '0x2540be400' }, } as unknown as TransactionMeta; - const params = getBumpParamsForCancelSpeedup(tx, false); - expect(params).toBeDefined(); - expect((params as GasPriceValue).gasPrice).toBeDefined(); + const result = getBumpParamsForCancelSpeedup(tx, false); + expect(result).toBeDefined(); + const { gasValues, userFeeLevel } = result as BumpParamsResult; + expect((gasValues as GasPriceValue).gasPrice).toBeDefined(); + expect(userFeeLevel).toBe(UserFeeLevel.CUSTOM); }); it('returns undefined when tx has no txParams', () => { @@ -372,4 +407,107 @@ describe('getBumpParamsForCancelSpeedup', () => { const params = getBumpParamsForCancelSpeedup(tx, false); expect(params).toBeUndefined(); }); + + it('uses market estimate when medium > gas + 10% (EIP-1559)', () => { + const lowGasTx = { + id: 'tx-3', + chainId: '0x1', + txParams: { + gas: '0x5208', + maxFeePerGas: '0x3B9ACA00', // 1 GWEI + maxPriorityFeePerGas: '0x3B9ACA00', // 1 GWEI + }, + } as unknown as TransactionMeta; + + const estimates = { + medium: { + suggestedMaxFeePerGas: '50', // 50 GWEI >> 1.1 GWEI + suggestedMaxPriorityFeePerGas: '2', + }, + } as GasFeeEstimatesInput; + + const result = getBumpParamsForCancelSpeedup( + lowGasTx, + false, + estimates, + ) as BumpParamsResult; + expect(result).toBeDefined(); + expect(result.userFeeLevel).toBe(GasFeeEstimateLevel.Medium); + const gasValues = result.gasValues as FeeMarketEIP1559Values; + expect(gasValues.maxFeePerGas).toBeDefined(); + expect(parseInt(gasValues.maxFeePerGas ?? '0', 16)).toBeGreaterThan( + parseInt('0x3B9ACA00', 16) * 1.1, + ); + }); + + it('uses 10% bump when medium < gas + 10% (EIP-1559)', () => { + const highGasTx = { + id: 'tx-4', + chainId: '0x1', + txParams: { + gas: '0x5208', + maxFeePerGas: '0x174876e800', // 100 GWEI + maxPriorityFeePerGas: '0x59682f00', // 1.5 GWEI + }, + } as unknown as TransactionMeta; + + const estimates = { + medium: { + suggestedMaxFeePerGas: '25', // 25 GWEI << 110 GWEI (100 * 1.1) + suggestedMaxPriorityFeePerGas: '2', + }, + } as GasFeeEstimatesInput; + + const result = getBumpParamsForCancelSpeedup( + highGasTx, + false, + estimates, + ) as BumpParamsResult; + expect(result).toBeDefined(); + expect(result.userFeeLevel).toBe(UserFeeLevel.CUSTOM); + const gasValues = result.gasValues as FeeMarketEIP1559Values; + const bumpedMaxFee = parseInt('0x174876e800', 16) * 1.1; + const resultMaxFee = parseInt(gasValues.maxFeePerGas ?? '0', 16); + expect(Math.abs(resultMaxFee - bumpedMaxFee)).toBeLessThan(1e9); + }); + + it('uses market estimate for legacy when medium > gas + 10%', () => { + const lowGasLegacyTx = { + id: 'tx-5', + chainId: '0x1', + txParams: { + gas: '0x5208', + gasPrice: '0x3B9ACA00', // 1 GWEI + }, + } as unknown as TransactionMeta; + + const estimates = { + medium: '50', // 50 GWEI >> 1.1 GWEI + } as GasFeeEstimatesInput; + + const result = getBumpParamsForCancelSpeedup( + lowGasLegacyTx, + false, + estimates, + ) as BumpParamsResult; + expect(result).toBeDefined(); + expect(result.userFeeLevel).toBe(GasFeeEstimateLevel.Medium); + const gasValues = result.gasValues as GasPriceValue; + expect(gasValues.gasPrice).toBeDefined(); + expect(parseInt(gasValues.gasPrice ?? '0', 16)).toBeGreaterThan( + parseInt('0x3B9ACA00', 16) * 1.1, + ); + }); + + it('falls back to 10% bump when no gasFeeEstimates provided', () => { + const result = getBumpParamsForCancelSpeedup( + eip1559Tx, + false, + ) as BumpParamsResult; + expect(result).toBeDefined(); + expect(result.userFeeLevel).toBe(UserFeeLevel.CUSTOM); + expect( + (result.gasValues as FeeMarketEIP1559Values).maxFeePerGas, + ).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.ts b/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.ts index 294b689dfbb..2187a267449 100644 --- a/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.ts +++ b/app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.ts @@ -1,7 +1,9 @@ import { CANCEL_RATE, + GasFeeEstimateLevel, isEIP1559Transaction, SPEED_UP_RATE, + UserFeeLevel, type FeeMarketEIP1559Values, type GasPriceValue, type TransactionMeta, @@ -11,10 +13,15 @@ import BigNumber from 'bignumber.js'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '../../../../../../reducers'; -import { selectGasFeeEstimates } from '../../../../../../selectors/confirmTransaction'; import { selectNetworkConfigurationByChainId } from '../../../../../../selectors/networkController'; import { selectTransactionMetadataById } from '../../../../../../selectors/transactionController'; -import { getMediumGasPriceHex } from '../../../../../../util/confirmation/gas'; +import { + type GasFeeEstimatesInput, + gasEstimateGreaterThanGasUsedPlusTenPercent, + getMediumEstimateGwei, + getMediumGasPriceHex, + getMediumPriorityFeeGwei, +} from '../../../../../../util/confirmation/gas'; import { decGWEIToHexWEI } from '../../../../../../util/conversions'; import { addHexPrefix } from '../../../../../../util/number'; import { useFeeCalculations } from '../useFeeCalculations'; @@ -31,23 +38,79 @@ const STUB_TX = { networkClientId: '', } as TransactionMeta; +/** + * Returns the medium market estimate as controller params when the market estimate + * exceeds the transaction's current gas + 10%. Returns undefined if the market estimate + * is not higher or if estimates are unavailable. + */ +function getMarketEstimateParams( + txParams: TransactionMeta['txParams'], + gasFeeEstimates: GasFeeEstimatesInput, +): GasPriceValue | FeeMarketEIP1559Values | undefined { + if (isEIP1559Transaction(txParams)) { + const mediumEstimateGwei = getMediumEstimateGwei(gasFeeEstimates); + const mediumPriorityGwei = getMediumPriorityFeeGwei(gasFeeEstimates) ?? '0'; + + if (!mediumEstimateGwei) return undefined; + + const maxFeePerGasHex = addHexPrefix( + decGWEIToHexWEI(mediumEstimateGwei)?.toString(), + ); + const maxPriorityFeePerGasHex = addHexPrefix( + decGWEIToHexWEI(mediumPriorityGwei)?.toString(), + ); + + return { + maxFeePerGas: maxFeePerGasHex, + maxPriorityFeePerGas: maxPriorityFeePerGasHex, + }; + } + + const gasPriceHex = getMediumGasPriceHex(gasFeeEstimates); + return { gasPrice: gasPriceHex }; +} + +export interface BumpParamsResult { + gasValues: GasPriceValue | FeeMarketEIP1559Values; + userFeeLevel: string; +} + /** * Returns gas params to use for speed up (bump) or cancel, for seeding the transaction * when the cancel/speed up modal opens. Used with updateTransactionGasFees so the gas * modal shows the suggested values; user can then edit via the gas modal. * - * @param gasFeeEstimates - From selectGasFeeEstimates; only used for legacy fallback when existing gasPrice is zero. + * If the medium network estimate exceeds the transaction's current gas + 10%, the market + * estimate is used. Otherwise, a flat 10% bump (CANCEL_RATE / SPEED_UP_RATE) is applied. + * + * @param tx - The transaction metadata to bump. + * @param isCancel - Whether the operation is a cancel (vs speed-up). + * @param gasFeeEstimates - Gas fee estimates; used for market comparison and legacy zero-price fallback. */ export function getBumpParamsForCancelSpeedup( tx: TransactionMeta, isCancel: boolean, - gasFeeEstimates?: ReturnType, -): GasPriceValue | FeeMarketEIP1559Values | undefined { + gasFeeEstimates?: GasFeeEstimatesInput, +): BumpParamsResult | undefined { const txParams = tx?.txParams; if (!tx || !txParams) { return undefined; } + const useMarketEstimate = + gasFeeEstimates && + gasEstimateGreaterThanGasUsedPlusTenPercent(txParams, gasFeeEstimates); + + if (useMarketEstimate) { + const marketParams = getMarketEstimateParams(txParams, gasFeeEstimates); + if (marketParams) { + return { + gasValues: marketParams, + userFeeLevel: GasFeeEstimateLevel.Medium, + }; + } + } + const rate = isCancel ? CANCEL_RATE : SPEED_UP_RATE; if (isEIP1559Transaction(txParams)) { @@ -74,8 +137,11 @@ export function getBumpParamsForCancelSpeedup( ); return { - maxFeePerGas: maxFeePerGasHex, - maxPriorityFeePerGas: maxPriorityFeePerGasHex, + gasValues: { + maxFeePerGas: maxFeePerGasHex, + maxPriorityFeePerGas: maxPriorityFeePerGasHex, + }, + userFeeLevel: UserFeeLevel.CUSTOM, }; } @@ -89,12 +155,13 @@ export function getBumpParamsForCancelSpeedup( let gasPriceHex = addHexPrefix(suggestedGasPrice.toString(16)); if (suggestedGasPrice.isZero() && gasFeeEstimates) { - gasPriceHex = getMediumGasPriceHex( - gasFeeEstimates as Parameters[0], - ); + gasPriceHex = getMediumGasPriceHex(gasFeeEstimates); } - return { gasPrice: gasPriceHex }; + return { + gasValues: { gasPrice: gasPriceHex }, + userFeeLevel: UserFeeLevel.CUSTOM, + }; } /** Extract controller params (gas fields only) from tx.txParams for speedUpTransaction/stopTransaction. */ @@ -138,6 +205,8 @@ export function useCancelSpeedupGas({ const feeCalculations = useFeeCalculations(tx ?? STUB_TX); + const isInitialGasReady = Boolean(tx?.previousGas); + return useMemo((): UseCancelSpeedupGasResult => { const empty: UseCancelSpeedupGasResult = { paramsForController: undefined, @@ -145,6 +214,7 @@ export function useCancelSpeedupGas({ networkFeeNative: '0', networkFeeFiat: null, nativeTokenSymbol, + isInitialGasReady, }; if (!tx?.txParams || !paramsForController) { @@ -159,6 +229,7 @@ export function useCancelSpeedupGas({ networkFeeNative, networkFeeFiat: feeCalculations.estimatedFeeFiat, nativeTokenSymbol, + isInitialGasReady, }; }, [ tx, @@ -166,5 +237,6 @@ export function useCancelSpeedupGas({ feeCalculations.estimatedFeeNative, feeCalculations.estimatedFeeFiat, nativeTokenSymbol, + isInitialGasReady, ]); } diff --git a/app/core/Engine/controllers/predict-controller/index.test.ts b/app/core/Engine/controllers/predict-controller/index.test.ts index ba9ebe54b54..d6c5b081c5d 100644 --- a/app/core/Engine/controllers/predict-controller/index.test.ts +++ b/app/core/Engine/controllers/predict-controller/index.test.ts @@ -8,6 +8,7 @@ import { } from '../../../../components/UI/Predict/controllers/PredictController'; import { predictControllerInit } from '.'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { ActiveOrderState } from '../../../../components/UI/Predict'; jest.mock( '../../../../components/UI/Predict/controllers/PredictController', @@ -73,7 +74,7 @@ describe('predict controller init', () => { withdrawTransaction: null, selectedPaymentToken: null, accountMeta: {}, - activeBuyOrder: null, + activeBuyOrders: {}, }; initRequestMock.persistedState = { diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index 50369ba44c4..11774ef2bfa 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -195,6 +195,7 @@ describe('ramps controller init', () => { }, }, orders: [], + providerAutoSelected: false, }; initRequestMock.persistedState = { diff --git a/app/selectors/rampsController/index.ts b/app/selectors/rampsController/index.ts index 1c86fea1d88..36ed322a96b 100644 --- a/app/selectors/rampsController/index.ts +++ b/app/selectors/rampsController/index.ts @@ -129,6 +129,16 @@ export const selectRampsOrdersForSelectedAccountGroup = createDeepEqualSelector( }, ); +/** + * Selects whether the current provider was auto-selected by the system + * (soft selection) rather than chosen by the user or derived from order history. + */ +export const selectProviderAutoSelected = createSelector( + selectRampsControllerState, + (rampsControllerState): boolean => + rampsControllerState?.providerAutoSelected ?? false, +); + /** * Selects the transak native provider state (isAuthenticated, userDetails, buyQuote, kycRequirement). */ diff --git a/app/util/confirmation/gas.test.ts b/app/util/confirmation/gas.test.ts index 4268528b927..1881740ea17 100644 --- a/app/util/confirmation/gas.test.ts +++ b/app/util/confirmation/gas.test.ts @@ -1,8 +1,17 @@ import { GasFeeEstimateType, GasFeeEstimateLevel, + TransactionParams, } from '@metamask/transaction-controller'; -import { getMediumGasPriceHex } from './gas'; +import { + getMediumGasPriceHex, + addTenPercentAndRound, + getMediumEstimateGwei, + getMediumPriorityFeeGwei, + gasEstimateGreaterThanGasUsedPlusTenPercent, + getGasValuesForReplacement, + type GasFeeEstimatesInput, +} from './gas'; jest.mock('../conversions', () => ({ decGWEIToHexWEI: jest.fn((value: string) => { @@ -21,6 +30,62 @@ jest.mock('../number', () => ({ const mockDecGWEIToHexWEI = jest.requireMock('../conversions').decGWEIToHexWEI; const mockAddHexPrefix = jest.requireMock('../number').addHexPrefix; +const ESTIMATES_FEE_MARKET = { + type: GasFeeEstimateType.FeeMarket, + [GasFeeEstimateLevel.Medium]: { suggestedMaxFeePerGas: '25' }, +}; + +const ESTIMATES_FEE_MARKET_WITH_PRIORITY = { + type: GasFeeEstimateType.FeeMarket, + [GasFeeEstimateLevel.Medium]: { + suggestedMaxFeePerGas: '25', + suggestedMaxPriorityFeePerGas: '2', + }, +}; + +const ESTIMATES_LEGACY = { + type: GasFeeEstimateType.Legacy, + [GasFeeEstimateLevel.Medium]: '20', +}; + +const ESTIMATES_GAS_PRICE = { + type: GasFeeEstimateType.GasPrice, + gasPrice: '15', +}; + +const ESTIMATES_UNTYPED_FEE_MARKET = { + medium: { suggestedMaxFeePerGas: '30' }, +}; + +const ESTIMATES_UNTYPED_FEE_MARKET_WITH_PRIORITY = { + medium: { + suggestedMaxFeePerGas: '30', + suggestedMaxPriorityFeePerGas: '1.5', + }, +}; + +const ESTIMATES_UNTYPED_STRING = { medium: '22' }; +const ESTIMATES_GAS_PRICE_FALLBACK = { gasPrice: '10' }; + +const EIP1559_TX_PARAMS = { + maxFeePerGas: '0x59682f10', + maxPriorityFeePerGas: '0x59682f00', +} as unknown as TransactionParams; + +const LEGACY_TX_PARAMS = { + gasPrice: '0x59682f10', +} as unknown as TransactionParams; + +const HIGH_FEE_MARKET_ESTIMATE = { + medium: { suggestedMaxFeePerGas: '70' }, +} as GasFeeEstimatesInput; + +const LOW_FEE_MARKET_ESTIMATE = { + medium: { suggestedMaxFeePerGas: '1' }, +} as GasFeeEstimatesInput; + +const toHex = (n: number) => `0x${n.toString(16)}`; + describe('getMediumGasPriceHex', () => { beforeEach(() => { jest.clearAllMocks(); @@ -137,3 +202,244 @@ describe('getMediumGasPriceHex', () => { expect(result).toBeDefined(); }); }); + +describe('addTenPercentAndRound', () => { + it('returns undefined when input is undefined', () => { + expect(addTenPercentAndRound(undefined)).toBeUndefined(); + }); + + it('returns undefined when input is empty string', () => { + expect(addTenPercentAndRound('')).toBeUndefined(); + }); + + it.each([ + [toHex(100), toHex(110), '100 -> 110'], + [toHex(15), toHex(16), '15 -> 16 (fractional floor)'], + [toHex(1_500_000_016), toHex(1_650_000_017), 'large value'], + ])('bumps %s to %s (%s)', (input, expected) => { + expect(addTenPercentAndRound(input)).toBe(expected); + }); +}); + +describe('getMediumEstimateGwei', () => { + it.each([ + ['null', null, undefined], + ['undefined', undefined, undefined], + ['FeeMarket typed', ESTIMATES_FEE_MARKET, '25'], + ['Legacy typed', ESTIMATES_LEGACY, '20'], + ['GasPrice typed', ESTIMATES_GAS_PRICE, '15'], + ['untyped fee-market object', ESTIMATES_UNTYPED_FEE_MARKET, '30'], + ['untyped string', ESTIMATES_UNTYPED_STRING, '22'], + ['gasPrice fallback', ESTIMATES_GAS_PRICE_FALLBACK, '10'], + ['empty object', {}, undefined], + ] as const)( + 'returns expected value for %s estimates', + (_, estimates, expected) => { + expect(getMediumEstimateGwei(estimates)).toBe(expected); + }, + ); +}); + +describe('getMediumPriorityFeeGwei', () => { + it.each([ + ['null', null, undefined], + ['undefined', undefined, undefined], + ['FeeMarket with priority', ESTIMATES_FEE_MARKET_WITH_PRIORITY, '2'], + ['FeeMarket without priority', ESTIMATES_FEE_MARKET, undefined], + ['Legacy typed', ESTIMATES_LEGACY, undefined], + ['GasPrice typed', ESTIMATES_GAS_PRICE, undefined], + [ + 'untyped with priority', + ESTIMATES_UNTYPED_FEE_MARKET_WITH_PRIORITY, + '1.5', + ], + ['untyped string', ESTIMATES_UNTYPED_STRING, undefined], + ['untyped max-fee only', ESTIMATES_UNTYPED_FEE_MARKET, undefined], + ] as const)( + 'returns expected value for %s estimates', + (_, estimates, expected) => { + expect(getMediumPriorityFeeGwei(estimates)).toBe(expected); + }, + ); +}); + +describe('gasEstimateGreaterThanGasUsedPlusTenPercent', () => { + it.each([ + [ + 'EIP-1559 high estimate', + EIP1559_TX_PARAMS, + HIGH_FEE_MARKET_ESTIMATE, + true, + ], + [ + 'EIP-1559 low estimate', + EIP1559_TX_PARAMS, + LOW_FEE_MARKET_ESTIMATE, + false, + ], + [ + 'no gas fields', + { gas: '0x5208' } as unknown as TransactionParams, + HIGH_FEE_MARKET_ESTIMATE, + false, + ], + [ + 'legacy high', + LEGACY_TX_PARAMS, + { medium: '70' } as GasFeeEstimatesInput, + true, + ], + [ + 'legacy low', + LEGACY_TX_PARAMS, + { medium: '1' } as GasFeeEstimatesInput, + false, + ], + ['null estimates', EIP1559_TX_PARAMS, null, false], + [ + 'fee-market est + legacy tx', + LEGACY_TX_PARAMS, + HIGH_FEE_MARKET_ESTIMATE, + true, + ], + ] as const)('%s', (_, txParams, estimates, expected) => { + expect( + gasEstimateGreaterThanGasUsedPlusTenPercent(txParams, estimates), + ).toBe(expected); + }); +}); + +describe('getGasValuesForReplacement', () => { + const RATE = 1.1; + + it.each([ + [ + 'previousGas is undefined', + { maxFeePerGas: toHex(16), maxPriorityFeePerGas: toHex(2) }, + undefined, + ], + [ + 'previousGas is null', + { maxFeePerGas: toHex(16), maxPriorityFeePerGas: toHex(2) }, + null, + ], + ] as const)( + 'returns gasValues unchanged when %s', + (_, gasValues, previousGas) => { + expect(getGasValuesForReplacement(gasValues, previousGas, RATE)).toBe( + gasValues, + ); + }, + ); + + it('returns undefined when gasValues is undefined', () => { + const previousGas = { + maxFeePerGas: toHex(16), + maxPriorityFeePerGas: toHex(2), + }; + expect( + getGasValuesForReplacement(undefined, previousGas, RATE), + ).toBeUndefined(); + }); + + describe('EIP-1559 flow', () => { + it('keeps user values when both exceed previousGas × rate', () => { + const previousGas = { + maxFeePerGas: toHex(100), + maxPriorityFeePerGas: toHex(50), + }; + const gasValues = { + maxFeePerGas: toHex(1000), + maxPriorityFeePerGas: toHex(500), + }; + const result = getGasValuesForReplacement(gasValues, previousGas, RATE); + expect(result).toEqual({ + maxFeePerGas: toHex(1000), + maxPriorityFeePerGas: toHex(500), + }); + }); + + it('clamps maxPriorityFeePerGas up when below previousGas × rate', () => { + const previousGas = { + maxFeePerGas: toHex(100), + maxPriorityFeePerGas: toHex(100), + }; + const gasValues = { + maxFeePerGas: toHex(1000), + maxPriorityFeePerGas: toHex(5), + }; + const result = getGasValuesForReplacement(gasValues, previousGas, RATE); + expect(result).toEqual({ + maxFeePerGas: toHex(1000), + maxPriorityFeePerGas: toHex(110), // ceil(100 × 1.1) + }); + }); + + it('clamps maxFeePerGas up when below previousGas × rate', () => { + const previousGas = { + maxFeePerGas: toHex(1000), + maxPriorityFeePerGas: toHex(50), + }; + const gasValues = { + maxFeePerGas: toHex(100), + maxPriorityFeePerGas: toHex(500), + }; + const result = getGasValuesForReplacement(gasValues, previousGas, RATE); + expect(result).toEqual({ + maxFeePerGas: toHex(1100), // ceil(1000 × 1.1) + maxPriorityFeePerGas: toHex(500), + }); + }); + + it('clamps both fields when both are below replacement minimum', () => { + const previousGas = { + maxFeePerGas: toHex(1000), + maxPriorityFeePerGas: toHex(100), + }; + const gasValues = { + maxFeePerGas: toHex(1), + maxPriorityFeePerGas: toHex(1), + }; + const result = getGasValuesForReplacement(gasValues, previousGas, RATE); + expect(result).toEqual({ + maxFeePerGas: toHex(1100), + maxPriorityFeePerGas: toHex(110), + }); + }); + + it('passes through when previousGas has no maxFeePerGas', () => { + const previousGas = { gasLimit: toHex(21000) }; + const gasValues = { + maxFeePerGas: toHex(1), + maxPriorityFeePerGas: toHex(1), + }; + expect(getGasValuesForReplacement(gasValues, previousGas, RATE)).toEqual( + gasValues, + ); + }); + }); + + describe('Legacy (gasPrice) flow', () => { + it('keeps user gasPrice when it exceeds previousGas × rate', () => { + const previousGas = { gasPrice: toHex(100) }; + const gasValues = { gasPrice: toHex(1000) }; + const result = getGasValuesForReplacement(gasValues, previousGas, RATE); + expect(result).toEqual({ gasPrice: toHex(1000) }); + }); + + it('clamps gasPrice up when below previousGas × rate', () => { + const previousGas = { gasPrice: toHex(100) }; + const gasValues = { gasPrice: toHex(5) }; + const result = getGasValuesForReplacement(gasValues, previousGas, RATE); + expect(result).toEqual({ gasPrice: toHex(110) }); // ceil(100 × 1.1) + }); + + it('passes through when previousGas has no gasPrice', () => { + const previousGas = { gasLimit: toHex(21000) }; + const gasValues = { gasPrice: toHex(5) }; + expect(getGasValuesForReplacement(gasValues, previousGas, RATE)).toEqual( + gasValues, + ); + }); + }); +}); diff --git a/app/util/confirmation/gas.ts b/app/util/confirmation/gas.ts index bab39051dc3..09c527cc7e1 100644 --- a/app/util/confirmation/gas.ts +++ b/app/util/confirmation/gas.ts @@ -1,99 +1,249 @@ import { GasFeeEstimateLevel, GasFeeEstimateType, + type FeeMarketEIP1559Values, type FeeMarketGasFeeEstimates, type GasFeeEstimates, type GasPriceGasFeeEstimates, + type GasPriceValue, type LegacyGasFeeEstimates, + type TransactionParams, } from '@metamask/transaction-controller'; +import { weiHexToGweiDec } from '@metamask/controller-utils'; +import BigNumber from 'bignumber.js'; import { decGWEIToHexWEI } from '../conversions'; import { addHexPrefix } from '../number'; +export type GasFeeEstimatesInput = + | GasFeeEstimates + | { medium?: unknown; gasPrice?: string } + | null + | undefined; + +interface FeeMarketMediumLevel { + suggestedMaxFeePerGas?: string; + suggestedMaxPriorityFeePerGas?: string; +} + +interface ResolvedMediumEstimate { + feeMarketLevel?: FeeMarketMediumLevel; + scalarGwei?: string; +} + /** - * Returns the hex gas price (in wei) for the "medium" tier from gas fee estimates. - * Used when a legacy tx has existing gasPrice 0x0 so cancel/speed-up can fall back to a mineable price. + * Extracts the medium-level gas fee estimate from various possible shapes of gas fee estimates. */ -export function getMediumGasPriceHex( - gasFeeEstimates: - | GasFeeEstimates - | { medium?: unknown; gasPrice?: string } - | null - | undefined, -): string { +function resolveMediumEstimate( + gasFeeEstimates: GasFeeEstimatesInput, +): ResolvedMediumEstimate { if (!gasFeeEstimates) { - return addHexPrefix(String(decGWEIToHexWEI('0'))); + return {}; } if ('type' in (gasFeeEstimates as object)) { const typedEstimates = gasFeeEstimates as GasFeeEstimates; - let estimateGweiDecimalRaw: string; - switch (typedEstimates.type) { case GasFeeEstimateType.FeeMarket: { const level = (typedEstimates as FeeMarketGasFeeEstimates)[ GasFeeEstimateLevel.Medium ]; - estimateGweiDecimalRaw = ( - level as unknown as { suggestedMaxFeePerGas: string } - ).suggestedMaxFeePerGas; - break; - } - case GasFeeEstimateType.Legacy: { - estimateGweiDecimalRaw = (typedEstimates as LegacyGasFeeEstimates)[ - GasFeeEstimateLevel.Medium - ] as unknown as string; - break; - } - case GasFeeEstimateType.GasPrice: { - estimateGweiDecimalRaw = (typedEstimates as GasPriceGasFeeEstimates) - .gasPrice as string; - break; - } - default: { - estimateGweiDecimalRaw = '0'; + return { + feeMarketLevel: level as unknown as FeeMarketMediumLevel, + }; } + case GasFeeEstimateType.Legacy: + return { + scalarGwei: (typedEstimates as LegacyGasFeeEstimates)[ + GasFeeEstimateLevel.Medium + ] as unknown as string, + }; + case GasFeeEstimateType.GasPrice: + return { + scalarGwei: (typedEstimates as GasPriceGasFeeEstimates) + .gasPrice as string, + }; + default: + return {}; } - - return addHexPrefix( - String(decGWEIToHexWEI(String(estimateGweiDecimalRaw))), - ); } - const maybeFeeMarket = ( - gasFeeEstimates as { - medium?: { suggestedMaxFeePerGas?: string } | string; - } + const mediumLevel = ( + gasFeeEstimates as { medium?: FeeMarketMediumLevel | string } ).medium; if ( - maybeFeeMarket && - typeof maybeFeeMarket === 'object' && - 'suggestedMaxFeePerGas' in maybeFeeMarket + mediumLevel && + typeof mediumLevel === 'object' && + ('suggestedMaxFeePerGas' in mediumLevel || + 'suggestedMaxPriorityFeePerGas' in mediumLevel) ) { - return addHexPrefix( - String( - decGWEIToHexWEI( - String( - (maybeFeeMarket as { suggestedMaxFeePerGas?: string }) - .suggestedMaxFeePerGas ?? '0', - ), - ), - ), - ); + return { feeMarketLevel: mediumLevel }; } - - if ( - maybeFeeMarket && - typeof maybeFeeMarket === 'string' && - maybeFeeMarket.length > 0 - ) { - return addHexPrefix(String(decGWEIToHexWEI(maybeFeeMarket))); + if (mediumLevel && typeof mediumLevel === 'string') { + return { scalarGwei: mediumLevel }; } const maybeGasPrice = (gasFeeEstimates as { gasPrice?: string }).gasPrice; if (maybeGasPrice) { - return addHexPrefix(String(decGWEIToHexWEI(String(maybeGasPrice)))); + return { scalarGwei: maybeGasPrice }; + } + + return {}; +} + +/** + * Returns the hex gas price (in wei) for the "medium" tier from gas fee estimates. + * Used when a legacy tx has existing gasPrice 0x0 so cancel/speed-up can fall back to a mineable price. + */ +export function getMediumGasPriceHex( + gasFeeEstimates: GasFeeEstimatesInput, +): string { + const gweiEstimate = getMediumEstimateGwei(gasFeeEstimates); + const gwei = gweiEstimate && gweiEstimate.length > 0 ? gweiEstimate : '0'; + return addHexPrefix(String(decGWEIToHexWEI(gwei))); +} + +/** + * Multiplies a hex wei value by 1.1 (10% increase) and rounds to integer wei. + * + * @param hexStringValue - Hex string in wei + * @returns 0x-prefixed hex string 10% higher, or undefined when input is falsy. + */ +export function addTenPercentAndRound( + hexStringValue: string | undefined, +): string | undefined { + if (!hexStringValue) { + return undefined; + } + const value = new BigNumber(hexStringValue, 16); + return addHexPrefix( + value.times(1.1).integerValue(BigNumber.ROUND_FLOOR).toString(16), + ); +} + +/** + * Extracts the medium-level gas fee estimate as a decimal GWEI string. + * Handles typed estimates (FeeMarket, Legacy, GasPrice) and untyped legacy shapes. + */ +export function getMediumEstimateGwei( + gasFeeEstimates: GasFeeEstimatesInput, +): string | undefined { + const { feeMarketLevel, scalarGwei } = resolveMediumEstimate(gasFeeEstimates); + return feeMarketLevel?.suggestedMaxFeePerGas ?? scalarGwei; +} + +/** + * Extracts the medium tier's `suggestedMaxPriorityFeePerGas` as a decimal GWEI string + * when present (EIP-1559 fee-market shapes only). Legacy string `medium`, GasPrice type, + * and `gasPrice` fallback do not carry priority and yield `undefined`. + */ +export function getMediumPriorityFeeGwei( + gasFeeEstimates: GasFeeEstimatesInput, +): string | undefined { + const { feeMarketLevel } = resolveMediumEstimate(gasFeeEstimates); + return feeMarketLevel?.suggestedMaxPriorityFeePerGas; +} + +/** + * Returns true when the medium network estimate is strictly greater than the + * transaction's current gas fee plus 10%. + * + * @param txParams - Transaction params containing maxFeePerGas (EIP-1559) or gasPrice (legacy). + * @param gasFeeEstimates - Gas fee estimates from GasFeeController / Redux. + * @returns true if market medium estimate exceeds the bumped (×1.1) transaction gas fee. + */ +export function gasEstimateGreaterThanGasUsedPlusTenPercent( + txParams: TransactionParams, + gasFeeEstimates: GasFeeEstimatesInput, +): boolean { + const gasInTransaction = txParams?.maxFeePerGas ?? txParams?.gasPrice; + const bumped = addTenPercentAndRound(gasInTransaction); + if (!bumped) { + return false; + } + const bumpedGwei = new BigNumber(String(weiHexToGweiDec(bumped))); + + const estimateGwei = getMediumEstimateGwei(gasFeeEstimates); + if (!estimateGwei) { + return false; + } + + return new BigNumber(String(estimateGwei)).gt(bumpedGwei); +} + +export interface PreviousGasParams { + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + gasPrice?: string; + gasLimit?: string; + gas?: string; +} + +/** + * Ensures gas values for a replacement (cancel/speed-up) transaction are not + * underpriced. For each gas field the result is the higher of the user-selected + * value or `previousGas × rate`. + * + * @param gasValues - Current gas values the user selected (from the gas modal). + * @param previousGas - Original gas values captured when the cancel/speed-up modal opened. + * @param rate - Multiplier for minimum replacement gas. + * @returns Gas values safe for replacement, or the input unchanged when previousGas is absent. + */ +export function getGasValuesForReplacement( + gasValues: GasPriceValue | FeeMarketEIP1559Values | undefined, + previousGas: PreviousGasParams | undefined | null, + rate: number, +): GasPriceValue | FeeMarketEIP1559Values | undefined { + if (!previousGas || !gasValues) { + return gasValues; } - return addHexPrefix(String(decGWEIToHexWEI('0'))); + const hexBN = (v: string | undefined | null): BigNumber => + v ? new BigNumber(addHexPrefix(String(v)), 16) : new BigNumber(0); + + // Legacy (gasPrice) flow + if ('gasPrice' in gasValues) { + if (!previousGas.gasPrice) { + return gasValues; + } + const minGasPrice = hexBN(previousGas.gasPrice) + .times(rate) + .integerValue(BigNumber.ROUND_CEIL); + const minGasPriceHex = addHexPrefix(minGasPrice.toString(16)); + + const gasPrice = hexBN(gasValues.gasPrice).gte(minGasPrice) + ? gasValues.gasPrice + : minGasPriceHex; + + return { gasPrice }; + } + + // EIP-1559 flow + if (!previousGas.maxFeePerGas || !previousGas.maxPriorityFeePerGas) { + return gasValues; + } + + const minMaxFee = hexBN(previousGas.maxFeePerGas) + .times(rate) + .integerValue(BigNumber.ROUND_CEIL); + const minPriorityFee = hexBN(previousGas.maxPriorityFeePerGas) + .times(rate) + .integerValue(BigNumber.ROUND_CEIL); + + const minMaxFeeHex = addHexPrefix(minMaxFee.toString(16)); + const minPriorityFeeHex = addHexPrefix(minPriorityFee.toString(16)); + + const maxFeePerGas = hexBN( + (gasValues as FeeMarketEIP1559Values).maxFeePerGas, + ).gte(minMaxFee) + ? (gasValues as FeeMarketEIP1559Values).maxFeePerGas + : minMaxFeeHex; + + const maxPriorityFeePerGas = hexBN( + (gasValues as FeeMarketEIP1559Values).maxPriorityFeePerGas, + ).gte(minPriorityFee) + ? (gasValues as FeeMarketEIP1559Values).maxPriorityFeePerGas + : minPriorityFeeHex; + + return { maxFeePerGas, maxPriorityFeePerGas }; } diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index cfac57977e4..9329b3f1cf9 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -652,6 +652,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "isLoading": false, "selected": null, }, + "providerAutoSelected": false, "providers": { "data": [], "error": null, @@ -1471,6 +1472,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "isLoading": false, "selected": null, }, + "providerAutoSelected": false, "providers": { "data": [], "error": null, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index cdb627b5918..e17682f544a 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -732,7 +732,7 @@ "pendingDeposits": {}, "pendingClaims": {}, "withdrawTransaction": null, - "activeBuyOrder": null, + "activeBuyOrders": {}, "selectedPaymentToken": null, "accountMeta": {} }, @@ -790,6 +790,7 @@ } }, "requests": {}, - "orders": [] + "orders": [], + "providerAutoSelected": false } } diff --git a/app/util/transaction-controller/index.test.ts b/app/util/transaction-controller/index.test.ts index f03f8769bbd..e34e219d083 100644 --- a/app/util/transaction-controller/index.test.ts +++ b/app/util/transaction-controller/index.test.ts @@ -17,6 +17,7 @@ const { estimateGas, getNetworkNonce, estimateGasFee, + getPreviousGasFromController, ...proxyMethods } = TransactionControllerUtils; @@ -96,6 +97,7 @@ jest.mock('../../core/Engine', () => ({ wipeTransactions: jest.fn(), updateEditableParams: jest.fn(), updateTransactionGasFees: jest.fn(), + updatePreviousGasParams: jest.fn(), updateAtomicBatchData: jest.fn(), addTransactionBatch: jest.fn(), updateSelectedGasFeeToken: jest.fn(), @@ -593,6 +595,24 @@ describe('Transaction Controller Util', () => { }); }); + describe('updatePreviousGasParams', () => { + it('delegates to TransactionController.updatePreviousGasParams', () => { + const transactionId = 'tx-123'; + const previousGas = { + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x2', + gasLimit: '0x5208', + }; + TransactionControllerUtils.updatePreviousGasParams( + transactionId, + previousGas, + ); + expect( + Engine.context.TransactionController.updatePreviousGasParams, + ).toHaveBeenCalledWith(transactionId, previousGas); + }); + }); + describe('isAtomicBatchSupported', () => { it('calls isAtomicBatchSupported with the request object and returns the result', async () => { const request = { diff --git a/app/util/transaction-controller/index.ts b/app/util/transaction-controller/index.ts index 8f29e9f62de..36949b99911 100644 --- a/app/util/transaction-controller/index.ts +++ b/app/util/transaction-controller/index.ts @@ -161,6 +161,25 @@ export function updateTransactionGasFees( return TransactionController.updateTransactionGasFees(...args); } +export function updatePreviousGasParams( + ...args: Parameters +) { + const { TransactionController } = Engine.context; + return TransactionController.updatePreviousGasParams(...args); +} + +/** + * Reads the latest `previousGas` for a transaction. + */ +export function getPreviousGasFromController(txId: string | null | undefined) { + if (!txId) return undefined; + const { TransactionController } = Engine.context; + const tx = TransactionController.getTransactions({ + searchCriteria: { id: txId }, + })?.[0]; + return tx?.previousGas; +} + export const getNetworkNonce = async ( { from }: { from: string }, networkClientId: NetworkClientId, diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index d8853cbea8d..3ca465e0924 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -101,6 +101,7 @@ platform :ios do testflight_options = { api_key: api_key, ipa: ipa_path, + reject_build_waiting_for_review: true, distribute_external: distribute_external, groups: groups, notify_external_testers: notify_external_testers, diff --git a/locales/languages/de.json b/locales/languages/de.json index c831716c9ff..3cff8671a3b 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Zugriff eingeschränkt", + "description_line1": "Diese Wallet-Adresse wurde beim Compliance-Screening markiert. Infolgedessen sind einige MetaMask-Dienste nicht verfügbar.", + "description_line2": "Wenn Sie dies für einen Fehler halten, wenden Sie sich an den Support, um eine Überprüfung anzufordern.", + "contact_support": "Support kontaktieren" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Biometrische Authentifizierung abgebrochen", "biometric_authentication_cancelled_title": "Biometrische Einrichtung fehlgeschlagen", "biometric_authentication_cancelled_description": "Bitte richten Sie die biometrische Authentifizierung in den Einstellungen erneut ein.", - "biometric_authentication_cancelled_button": "Bestätigen" + "biometric_authentication_cancelled_button": "Bestätigen", + "biometric_changed": "Biometrisch geändert", + "biometric_changed_alert_desc": "Ihre Biometrie wurde geändert. Aktivieren Sie bitte die biometrischen Daten in den Einstellungen erneut.", + "biometric_changed_alert_confirm": "Bestätigen" }, "connect_hardware": { "title_select_hardware": "Verbinden Sie eine Hardware-Wallet", @@ -1008,6 +1011,11 @@ "see_more": "Mehr sehen" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Perps nicht verfügbar", "title": "Perps", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Stop-Loss muss {{direction}} {{priceType}} Preis sein", "stop_loss_beyond_liquidation_error": "Stop-Loss muss {{direction}} Liquidationspreis sein", "stop_loss_order_view_warning": "Stop-Loss ist {{direction}} Liquidationspreis", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Take-Profit muss {{direction}} {{priceType}} Preis sein. Aktualisieren oder löschen Sie den Wert, um die Order zu platzieren.", + "stop_loss_wrong_side_warning": "Stop-Loss muss {{direction}} {{priceType}} Preis sein. Aktualisieren oder löschen Sie den Wert, um die Order zu platzieren.", "above": "Über", "below": "Unter", "done": "Fertig", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "{{time}} Sekunden geschätzt", "placing_prediction": "Platzierung einer Prognose", "prediction_placed": "Prognose platziert", + "prediction_failed": "Failed to place prediction", "order_failed": "Bestellung fehlgeschlagen", "payments_made_in_usdc": "Alle Zahlungen erfolgen in USDC", "prediction_insufficient_funds": "Nicht genügend Mittel. Sie können bis zu {{amount}} nutzen.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Order fehlgeschlagen", "order_failed_body": "Die Liquidität war für diesen Preis nicht ausreichend. Möchten Sie es erneut versuchen?", "try_again": "Erneut versuchen", + "view": "Ansehen", "yes_buy": "Ja, kaufen", "yes_sell": "Ja, verkaufen" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Ein unerwarteter Fehler ist aufgetreten", "order_not_fully_filled": "Ausführung Ihrer Order fehlgeschlagen", "buy_order_not_fully_filled": "Nicht genügend Aktien zum Marktpreis verfügbar, um Ihre Order jetzt zu platzieren.", - "sell_order_not_fully_filled": "Die Nachfrage zum Marktpreis reicht derzeit nicht aus für eine Auszahlung." + "sell_order_not_fully_filled": "Die Nachfrage zum Marktpreis reicht derzeit nicht aus für eine Auszahlung.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Einen Gewinner auswählen", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Finanzierte Prognosen", "tx_review_predict_claim": "Eingeforderte Gewinne", "tx_review_predict_withdraw": "Prognosenwiderruf", + "tx_review_perps_withdraw": "Perps-Auszahlung", "tx_review_musd_conversion": "mUSD-Konvertierung", "claim": "Einfordern", "sent_ether": "ETH senden", @@ -5992,6 +6004,7 @@ "percentage_bonus": "Bonus von {{percentage}} %", "claimable_bonus": "Anspruchsberechtigter Bonus", "claim_bonus": "Bonus einfordern", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Der Bonus wird auf {{networkName}} ausgezahlt.", "percentage_bonus_on_linea": "{{percentage}} % Bonus auf Linea", "claim": "Einfordern", @@ -6151,54 +6164,54 @@ "your_stablecoins": "Ihre Stablecoins" }, "money": { - "title": "Money", - "apy_label": "{{percentage}}% APY", + "title": "Geld", + "apy_label": "APY von {{percentage}} %", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Hinzufügen", + "transfer": "Übertragung", + "card": "Karte" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Ihre Position", + "current_rate": "Aktueller Kurs", + "lifetime_earnings": "Lebenslange Einnahmen", + "available_balance": "Verfügbares Guthaben" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Wie es funktioniert", + "description": "Halten Sie mUSD auf einem Geldkonto und verdienen Sie automatisch. Er ist durch den Dollar gedeckt, stets liquide und kann jederzeit ausgegeben, gehandelt oder versendet werden.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Hinzufügen" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Mögliche Einnahmen", + "amount": "über 26.800 $", + "description": "Sehen Sie zu, wie Ihr Geld mit der Zeit wächst, indem Sie Ihre Kryptowährung in mUSD konvertieren.", + "convert": "Konvertieren", + "no_fee": "Keine Gebühr", + "see_earnings": "Mögliche Einnahmen anzeigen" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMask-Karte", + "subtitle": "Geben Sie Ihr Geld überall aus.", + "virtual_card": "Virtuelle Karte", + "metal_card": "Metallkarte", + "cashback": "{{percentage}} % Cashback", + "get_now": "Jetzt sichern" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Warum MetaMask Geld?", + "benefit_auto_earn": "Automatisch verdienen ", + "benefit_dollar_backed": "Ihr Geld wird in mUSD gehalten, einem 1:1 durch Dollar gedeckten Stablecoin", + "benefit_liquidity": "Volle Liquidität ohne Sperrfristen, sodass Sie jederzeit handeln oder auszahlen können", + "benefit_spend_prefix": "Geben Sie bei über 150 Mio. Händlern mit MetaMask Card aus und verdienen Sie dabei ", + "benefit_spend_cashback": "1–3 % Cashback", + "benefit_global": "Senden und empfangen Sie Geld weltweit ohne Zwischenhändler", + "learn_more": "Mehr erfahren" }, "footer": { - "add_money": "Add money" + "add_money": "Geld aufladen" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Konto-Update", "approve": "Anfrage genehmigen", "perps_deposit": "Gelder hinzufügen", + "perps_withdraw": "Auszahlen", "predict_deposit": "Prognosegelder hinzufügen", "predict_withdraw": "Auszahlen", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Diese Website möchte die Genehmigung, Ihre Tokens auszugeben.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Transaktion {{index}}", "transaction": "Transaktion", "available_balance": "Verfügbares Guthaben: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Verfügbares Perps-Guthaben: ", "edit_amount_done": "Fortfahren", "deposit_edit_amount_done": "Gelder hinzufügen", "deposit_edit_amount_predict_withdraw": "Auszahlen", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Erneut versuchen", "no_internet_connection_title": "Verbindungsherstellung nicht möglich", "no_internet_connection_description": "Ihre Internetverbindung ist instabil. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", - "no_internet_connection_button": "Erneut versuchen" + "no_internet_connection_button": "Erneut versuchen", + "ios_need_update_title": "iOS-Aktualisierung erforderlich", + "ios_need_update_description": "MetaMask-Google-Anmeldung erfordert bald ", + "ios_need_update_description_version": "iOS 17.4 oder höher", + "ios_need_update_description_end": ". Sie können die Google-Anmeldung auf diesem Gerät vorerst weiter verwenden, sie wird jedoch in einem kommenden Update nicht mehr unterstützt.", + "ios_need_update_description2": "Sie können weiterhin über dasselbe Google-Konto auf einem unterstützten Gerät bzw. über die MetaMask-Erweiterung auf Ihre Wallet zugreifen. Wir empfehlen Ihnen dringend, eine Sicherungskopie Ihrer geheimen Wiederherstellungsphrase zu erstellen, um einen ununterbrochenen Zugriff zu gewährleisten.", + "ios_need_update_button": "Fortfahren" }, "password_hint": { "title": "Passworthinweis", @@ -7430,7 +7450,9 @@ "loading": "Verfügbare Tokens werden geladen ...", "load_error": "Tokens können nicht geladen werden. Bitte versuchen Sie es erneut.", "retry": "Erneut versuchen", - "on_linea": "auf Linea" + "on_linea": "auf Linea", + "account_label": "Konto", + "token_label": "Token" }, "cashback_screen": { "title": "Cashback", @@ -7881,6 +7903,7 @@ "daily_bonus": "Täglich einforderbarer Bonus", "annualized_bonus": "Jährlicher Bonus", "disclaimer": "Dies ist nur eine Schätzung. Der Bonus kann sich noch ändern.", + "disclaimer_brief": "Der Bonus ist ein Schätzwert und kann sich ändern.", "buy_button": "mUSD kaufen", "swap_button": "Swap zu mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Beginnt am {{date}}", "ends_date": "Endet am {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Als Nächstes", + "ended_date": "Beendet am {{date}}", + "pill_up_next": "Demnächst verfügbar", "pill_active": "Live", "pill_complete": "Abgeschlossen", "enter_now": "Jetzt eingeben", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Ergänzende Nutzungsbedingungen und Datenschutzhinweis", "opt_in_sheet_description_post_link": "Wir verfolgen die Onchain-Aktivitäten und belohnen Sie automatisch.", "geo_restriction_banner_title": "In Ihrer Region nicht verfügbar", - "geo_restriction_banner_description": "Diese Kampagne ist in Ihrer Region aufgrund lokaler Bestimmungen nicht verfügbar." + "geo_restriction_banner_description": "Diese Kampagne ist in Ihrer Region aufgrund lokaler Bestimmungen nicht verfügbar.", + "opt_in_success_toast": "Sie sind dabei!" }, "campaign_mechanics": { "title": "Mechaniken" @@ -7945,10 +7969,60 @@ "opted_in": "Sie haben sich für diese Kampagne angemeldet", "opt_in_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", "join_campaign": "An der Kampagne teilnehmen", + "entries_closed_title": "Anmeldung geschlossen", + "entries_closed_description": "Sie haben die Anmeldefrist verpasst", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Überprüfung des Anmeldestatus", "swap": "Tauschen", "how_it_works": "Wie es funktioniert" }, + "ondo_campaign_leaderboard_position": { + "title": "Ihre Position", + "rank": "Mein Rang", + "tier": "Meine Stufe", + "rate_of_return": "Gewinn", + "total_deposited": "Gesamteinzahlung", + "current_value": "Aktueller Wert", + "not_found": "Noch nicht in der Rangliste. Bitte schauen Sie später noch einmal vorbei.", + "updated_at": "Zuletzt aktualisiert: {{time}}", + "error_loading": "Laden Ihrer Position fehlgeschlagen", + "error_loading_description": "Beim Laden Ihrer Ranglistenposition ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "retry": "Erneut versuchen" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Gesamtwert", + "portfolio_pnl": "GuV", + "portfolio_pnl_percent": "GuV (%)", + "summary_cost_basis": "Kostenbasis", + "summary_net_deposit": "Nettoeinzahlung", + "position_current_value": "Wert", + "position_unrealized_pnl": "GuV", + "updated_at": "Zuletzt aktualisiert: {{time}}", + "error_loading": "Laden der Positionen fehlgeschlagen.", + "error_loading_description": "Beim Laden Ihrer Position ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "retry": "Erneut versuchen", + "loading": "Positionen werden geladen ...", + "empty": "Noch keine Positionen gefunden.", + "empty_description": "Sammeln Sie Belohnungen, indem Sie eine Position in tokenisierten realen Assets eröffnen.", + "empty_cta": "Eine Position öffnen", + "position_units": "{{units}} Aktien" + }, + "ondo_campaign_leaderboard": { + "title": "Rangliste", + "your_position": "Ihre Position", + "of_total": "von {{total}} Teilnehmern", + "total_participants": "{{count}} Teilnehmer", + "updated_at": "Zuletzt aktualisiert: {{time}}", + "error_loading": "Laden der Rangliste fehlgeschlagen", + "error_loading_description": "Beim Laden der Rangliste ist etwas schiefgelaufen. Bitte versuchen Sie es erneut.", + "error_loading_position": "Laden Ihrer Position fehlgeschlagen", + "retry": "Erneut versuchen", + "no_data": "Keine Rangliste verfügbar", + "no_entries_in_tier": "Noch keine Teilnehmer auf dieser Stufe", + "not_yet_computed": "Die Rangliste wurde noch nicht errechnet. Schauen Sie bald wieder vorbei." + }, "campaigns_preview": { "title": "Kampagnen", "coming_soon": "Demnächst verfügbar", @@ -7983,6 +8057,7 @@ "musd_conversion": "Zu mUSD konvertiert", "musd_claim": "mUSD beansprucht", "perps_deposit": "Finanziertes Perps-Konto", + "perps_withdraw": "Auszahlung", "predict_claim": "Gewinne eingefordert", "predict_deposit": "Finanziertes Prognosekonto", "predict_withdraw": "Auszahlung", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceSymbol}} von {{sourceChain}} gesendet", "musd_claim": "mUSD beanspruchen", "perps_deposit": "Gelder hinzufügen", + "perps_withdraw": "Auszahlung", "predict_deposit": "Gelder hinzufügen", "swap": "Tokens tauschen", "swap_approval": "Tokens genehmigen" @@ -8082,6 +8158,7 @@ "sites": "Websites", "popular_sites": "Beliebte Websites", "search_sites": "Websites durchsuchen", + "view_all": "Alle anzeigen", "enable_basic_functionality": "Grundlegende Funktionen aktivieren", "basic_functionality_disabled_title": "Entdecken ist nicht verfügbar", "basic_functionality_disabled_description": "Die erforderlichen Metadaten können nicht abgerufen werden, wenn die Basisfunktionalität deaktiviert ist.", @@ -8199,6 +8276,7 @@ "perpetuals": "Perpetuals", "predictions": "Prognosen", "whats_happening": "Was ist passiert?", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolitisch", "macro": "Makro", diff --git a/locales/languages/el.json b/locales/languages/el.json index f8360590771..88826d23abe 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Περιορισμένη πρόσβαση", + "description_line1": "Αυτή η διεύθυνση πορτοφολιού έχει επισημανθεί κατά τον έλεγχο συμμόρφωσης. Ως αποτέλεσμα, ορισμένες υπηρεσίες στο MetaMask δεν είναι διαθέσιμες.", + "description_line2": "Αν θεωρείτε ότι έχει γίνει λάθος, επικοινωνήστε με την υποστήριξη για επανέλεγχο.", + "contact_support": "Επικοινωνία με την υποστήριξη" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Η επαλήθευση βιομετρικών δεδομένων ακυρώθηκε", "biometric_authentication_cancelled_title": "Η ρύθμιση βιομετρικών δεδομένων απέτυχε", "biometric_authentication_cancelled_description": "Παρακαλούμε ρυθμίστε εκ νέου τον έλεγχο ταυτότητας βιομετρικών δεδομένων από τις ρυθμίσεις.", - "biometric_authentication_cancelled_button": "Επιβεβαίωση" + "biometric_authentication_cancelled_button": "Επιβεβαίωση", + "biometric_changed": "Τα βιομετρικά στοιχεία άλλαξαν", + "biometric_changed_alert_desc": "Τα βιομετρικά σας στοιχεία έχουν αλλάξει. Παρακαλούμε ενεργοποιήστε ξανά τα βιομετρικά στοιχεία από τις ρυθμίσεις.", + "biometric_changed_alert_confirm": "Επιβεβαίωση" }, "connect_hardware": { "title_select_hardware": "Συνδέστε ένα πορτοφόλι υλικού", @@ -1008,6 +1011,11 @@ "see_more": "Δείτε περισσότερα" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Τα Perps δεν είναι διαθέσιμα", "title": "Συμβ.αορ.", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή {{priceType}}", "stop_loss_beyond_liquidation_error": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή ρευστοποίησης", "stop_loss_order_view_warning": "Η τιμή περιορισμού ζημιάς είναι {{direction}} από την τιμή ρευστοποίησης", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Η λήψη κερδών πρέπει να είναι σε {{direction}} {{priceType}} τιμή. Ενημερώστε ή διαγράψτε την τιμή για να προχωρήσετε στην εντολή.", + "stop_loss_wrong_side_warning": "Ο περιορισμός ζημιών πρέπει να είναι σε {{direction}} {{priceType}} τιμή. Ενημερώστε ή διαγράψτε την τιμή για να προχωρήσετε στην εντολή.", "above": "πάνω", "below": "κάτω", "done": "Τέλος", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Εκτιμώμενος χρόνος: {{time}} δευτερόλεπτα", "placing_prediction": "Καταχώρηση πρόβλεψης", "prediction_placed": "H πρόβλεψη καταχωρήθηκε", + "prediction_failed": "Failed to place prediction", "order_failed": "Η παραγγελία απέτυχε", "payments_made_in_usdc": "Όλες οι πληρωμές γίνονται σε USDC", "prediction_insufficient_funds": "Μη επαρκές υπόλοιπο. Μπορείτε να χρησιμοποιήσετε έως {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Η εντολή απέτυχε", "order_failed_body": "Δεν υπήρχε αρκετή ρευστότητα σε αυτή την τιμή. Θέλετε να δοκιμάσετε ξανά;", "try_again": "Προσπαθήστε ξανά", + "view": "Προβολή", "yes_buy": "Ναι, αγοράστε", "yes_sell": "Ναι, πουλήστε" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Παρουσιάστηκε άγνωστο σφάλμα", "order_not_fully_filled": "Αποτυχία εκτέλεσης της εντολής σας", "buy_order_not_fully_filled": "Δεν υπάρχουν αρκετές διαθέσιμες μετοχές στην τιμή αγοράς για να εκτελεστεί η εντολή σας αυτή τη στιγμή.", - "sell_order_not_fully_filled": "Δεν υπάρχει αρκετή ζήτηση στην τιμή αγοράς για να πραγματοποιηθεί η εξαργύρωση αυτή τη στιγμή." + "sell_order_not_fully_filled": "Δεν υπάρχει αρκετή ζήτηση στην τιμή αγοράς για να πραγματοποιηθεί η εξαργύρωση αυτή τη στιγμή.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Επιλέξτε έναν νικητή", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Χρηματοδοτούμενες προβλέψεις", "tx_review_predict_claim": "Κατοχυρωμένες νίκες", "tx_review_predict_withdraw": "Ανάληψη προβλέψεων", + "tx_review_perps_withdraw": "Ανάληψη από Perps", "tx_review_musd_conversion": "Μετατροπή σε mUSD", "claim": "Εξαργύρωση", "sent_ether": "Αποστολή ETH", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}% μπόνους", "claimable_bonus": "Μπόνους προς εξαργύρωση", "claim_bonus": "Εξαργύρωση του μπόνους", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Το μπόνους θα καταβληθεί στο δίκτυο {{networkName}}.", "percentage_bonus_on_linea": "{{percentage}}% μπόνους στο δίκτυο Linea", "claim": "Εξαργύρωση", @@ -6154,51 +6167,51 @@ "title": "Money", "apy_label": "{{percentage}}% APY", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Προσθήκη", + "transfer": "Μεταφορά", + "card": "Κάρτα" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Η θέση σας", + "current_rate": "Τρέχουσα τιμή", + "lifetime_earnings": "Συνολικά κέρδη", + "available_balance": "Διαθέσιμο υπόλοιπο" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Πώς λειτουργεί", + "description": "Διατηρήστε τα mUSD σε Λογαριασμό Κεφαλαίων και κερδίστε αυτόματα. Υποστηρίζεται από το δολάριο, με συνεχή ρευστότητα και είναι έτοιμα για χρήση, συναλλαγές ή αποστολές οποιαδήποτε στιγμή.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Προσθήκη" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Πιθανά κέρδη", + "amount": "+$26.800", + "description": "Δείτε πώς τα χρήματά σας μπορούν να αυξηθούν με τον χρόνο, μετατρέποντας τα κρυπτονομίσματά σας σε mUSD.", + "convert": "Μετατροπή", + "no_fee": "Χωρίς τέλος συναλλαγής", + "see_earnings": "Δείτε τα πιθανά κέρδη" }, "metamask_card": { "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "subtitle": "Ξοδέψτε τα χρήματά σας όπου θέλετε.", + "virtual_card": "Εικονική κάρτα", + "metal_card": "Μεταλλική κάρτα", + "cashback": "{{percentage}}% επιστροφή χρημάτων", + "get_now": "Αποκτήστε την τώρα" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Γιατί να επιλέξετε το MetaMask Money;", + "benefit_auto_earn": "Αυτόματη απόδοση ", + "benefit_dollar_backed": "Τα χρήματά σας διατηρούνται σε mUSD, τα stablecoins υποστηρίζονται πλήρως από το δολάριο", + "benefit_liquidity": "Πλήρης ρευστότητα χωρίς δεσμεύσεις, ώστε να μπορείτε να κάνετε συναλλαγές ή ανάληψη οποιαδήποτε στιγμή", + "benefit_spend_prefix": "Κάντε αγορές με τη MetaMask Card σε 150M+ σημεία και κερδίστε ", + "benefit_spend_cashback": "1–3% επιστροφή χρημάτων", + "benefit_global": "Στείλτε και λάβετε χρήματα παγκοσμίως, χωρίς μεσάζοντες", + "learn_more": "Μάθετε περισσότερα" }, "footer": { - "add_money": "Add money" + "add_money": "Προσθήκη χρημάτων" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Ενημέρωση λογαριασμού", "approve": "Έγκριση αιτήματος", "perps_deposit": "Προσθήκη κεφαλαίων", + "perps_withdraw": "Ανάληψη", "predict_deposit": "Προσθήκη κεφαλαίων για Προβλέψεις", "predict_withdraw": "Ανάληψη", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Αυτός ο ιστότοπος θέλει άδεια για να δαπανήσει τα tokens σας.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Συναλλαγή {{index}}", "transaction": "Προστασία", "available_balance": "Διαθέσιμο υπόλοιπο: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Διαθέσιμο υπόλοιπο για Perps: ", "edit_amount_done": "Συνεχίστε", "deposit_edit_amount_done": "Προσθήκη κεφαλαίων", "deposit_edit_amount_predict_withdraw": "Ανάληψη", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Προσπαθήστε ξανά", "no_internet_connection_title": "Δεν ήταν δυνατή η σύνδεση", "no_internet_connection_description": "Η σύνδεσή σας στο διαδίκτυο είναι ασταθής. Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά.", - "no_internet_connection_button": "Προσπαθήστε ξανά" + "no_internet_connection_button": "Προσπαθήστε ξανά", + "ios_need_update_title": "Απαιτείται ενημέρωση του iOS", + "ios_need_update_description": "Η σύνδεση μέσω Google στο MetaMask θα απαιτεί σύντομα ", + "ios_need_update_description_version": "iOS 17.4 ή νεότερο", + "ios_need_update_description_end": ". Μπορείτε προς το παρόν να συνεχίσετε να χρησιμοποιείτε τη σύνδεση μέσω Google σε αυτή τη συσκευή, αλλά σύντομα δεν θα υποστηρίζεται σε επόμενη ενημέρωση.", + "ios_need_update_description2": "Μπορείτε ακόμη να έχετε πρόσβαση στο πορτοφόλι σας με τον ίδιο λογαριασμό Google από υποστηριζόμενη συσκευή ή μέσω επέκτασης του MetaMask. Συνιστούμε ανεπιφύλακτα να δημιουργήσετε αντίγραφο ασφαλείας της Μυστικής Φράσης Ανάκτησης για να διασφαλίσετε απρόσκοπτη πρόσβαση.", + "ios_need_update_button": "Συνεχίστε" }, "password_hint": { "title": "Υπόδειξη κωδικού πρόσβασης", @@ -7430,7 +7450,9 @@ "loading": "Φόρτωση διαθέσιμων tokens...", "load_error": "Δεν ήταν δυνατή η φόρτωση των tokens. Παρακαλούμε δοκιμάστε ξανά.", "retry": "Προσπαθήστε ξανά", - "on_linea": "στο δίκτυο Linea" + "on_linea": "στο δίκτυο Linea", + "account_label": "Λογαριασμός", + "token_label": "Token" }, "cashback_screen": { "title": "Επιστροφή χρημάτων", @@ -7881,6 +7903,7 @@ "daily_bonus": "Ημερήσιο διαθέσιμο μπόνους", "annualized_bonus": "Ετήσιο μπόνους", "disclaimer": "Πρόκειται μόνο για εκτίμηση. Το μπόνους ενδέχεται να αλλάξει.", + "disclaimer_brief": "Το μπόνους είναι κατ' εκτίμηση και μπορεί να αλλάξει.", "buy_button": "Αγοράστε mUSD", "swap_button": "Ανταλλαγή σε mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Αρχίζει {{date}}", "ends_date": "Λήγει {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Επόμενο", + "ended_date": "Έληξε στις {{date}}", + "pill_up_next": "Προσεχώς", "pill_active": "Ζωντανά", "pill_complete": "Ολοκληρώθηκε", "enter_now": "Συμμετάσχετε τώρα", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Συμπληρωματικοί Όροι Χρήσης και Δήλωση Απορρήτου", "opt_in_sheet_description_post_link": "Θα παρακολουθούμε τη δραστηριότητά σας στο blockchain ώστε να σας ανταμείβουμε αυτόματα.", "geo_restriction_banner_title": "Δεν είναι διαθέσιμη στην περιοχή σας", - "geo_restriction_banner_description": "Αυτή η καμπάνια δεν είναι διαθέσιμη στην περιοχή σας λόγω τοπικών κανονισμών." + "geo_restriction_banner_description": "Αυτή η καμπάνια δεν είναι διαθέσιμη στην περιοχή σας λόγω τοπικών κανονισμών.", + "opt_in_success_toast": "Τα καταφέρατε!" }, "campaign_mechanics": { "title": "Κανόνες" @@ -7945,10 +7969,60 @@ "opted_in": "Έχετε εγγραφεί σε αυτή την καμπάνια", "opt_in_error": "Η εγγραφή απέτυχε. Προσπαθήστε ξανά.", "join_campaign": "Συμμετάσχετε στην καμπάνια", + "entries_closed_title": "Οι συμμετοχές έκλεισαν", + "entries_closed_description": "Έληξε το χρονικό περιθώριο συμμετοχών", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Έλεγχος κατάστασης εγγραφής", "swap": "Ανταλλαγή", "how_it_works": "Πώς λειτουργεί" }, + "ondo_campaign_leaderboard_position": { + "title": "Η θέση σας", + "rank": "Η κατάταξή μου", + "tier": "Το επίπεδό μου", + "rate_of_return": "Επιστροφή", + "total_deposited": "Συνολικά ποσά που κατατέθηκαν", + "current_value": "Τρέχουσα αποτίμηση", + "not_found": "Δεν βρίσκεστε ακόμη στον πίνακα κατάταξης. Παρακαλούμε ελέγξτε ξανά αργότερα.", + "updated_at": "Τελευταία ενημέρωση: {{time}}", + "error_loading": "Δεν ήταν δυνατή η φόρτωση της θέσης σας", + "error_loading_description": "Παρουσιάστηκε σφάλμα κατά τη φόρτωση της θέσης σας στον πίνακα κατάταξης. Παρακαλούμε προσπαθήστε πάλι.", + "retry": "Επανάληψη" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Συνολική αξία", + "portfolio_pnl": "Κ&Ζ", + "portfolio_pnl_percent": "Κ&Ζ (%)", + "summary_cost_basis": "Κόστος κτήσης", + "summary_net_deposit": "Καθαρή κατάθεση", + "position_current_value": "Αξία", + "position_unrealized_pnl": "Κ&Ζ", + "updated_at": "Τελευταία ενημέρωση: {{time}}", + "error_loading": "Δεν ήταν δυνατή η φόρτωση των θέσεων.", + "error_loading_description": "Παρουσιάστηκε σφάλμα κατά τη φόρτωση των θέσεών σας. Παρακαλούμε προσπαθήστε πάλι.", + "retry": "Επανάληψη", + "loading": "Φόρτωση θέσεων...", + "empty": "Δεν βρέθηκαν θέσεις ακόμη.", + "empty_description": "Κερδίστε ανταμοιβές ανοίγοντας μια θέση σε πραγματικά ψηφιακά περιουσιακά στοιχεία ως token.", + "empty_cta": "Ανοίξτε μια θέση", + "position_units": "{{units}} μετοχές" + }, + "ondo_campaign_leaderboard": { + "title": "Πίνακας κατάταξης", + "your_position": "Η θέση σας", + "of_total": "από {{total}} συμμετέχοντες", + "total_participants": "{{count}} συμμετέχοντες", + "updated_at": "Τελευταία ενημέρωση: {{time}}", + "error_loading": "Δεν ήταν δυνατή η φόρτωση του πίνακα κατάταξης", + "error_loading_description": "Κάτι πήγε στραβά κατά τη φόρτωση του πίνακα κατάταξης. Παρακαλούμε προσπαθήστε πάλι.", + "error_loading_position": "Δεν ήταν δυνατή η φόρτωση της θέσης σας", + "retry": "Επανάληψη", + "no_data": "Δεν υπάρχουν διαθέσιμα δεδομένα για τον πίνακα κατάταξης", + "no_entries_in_tier": "Δεν υπάρχουν ακόμη συμμετέχοντες σε αυτό το επίπεδο", + "not_yet_computed": "Ο υπολογισμός του πίνακα κατάταξης δεν έχει ολοκληρωθεί ακόμη. Ελέγξτε ξανά σύντομα." + }, "campaigns_preview": { "title": "Καμπάνιες", "coming_soon": "Προσεχώς", @@ -7983,6 +8057,7 @@ "musd_conversion": "Έγινε μετατροπή σε mUSD", "musd_claim": "Έγινε εξαργύρωση σε mUSD", "perps_deposit": "Χρηματοδότηση για perps", + "perps_withdraw": "Ανάληψη", "predict_claim": "Κέρδη που καταβλήθηκαν", "predict_deposit": "Λογαριασμός Προβλέψεων με διαθέσιμα κεφάλαια", "predict_withdraw": "Ανάληψη", @@ -8010,6 +8085,7 @@ "musd_convert_send": "Εστάλη {{sourceSymbol}} από το {{sourceChain}}", "musd_claim": "Κάντε εξαργύρωση σε mUSD", "perps_deposit": "Προσθήκη κεφαλαίων", + "perps_withdraw": "Ανάληψη", "predict_deposit": "Προσθήκη κεφαλαίων", "swap": "Ανταλλαγή tokens", "swap_approval": "Έγκριση token" @@ -8082,6 +8158,7 @@ "sites": "Ιστότοποι", "popular_sites": "Δημοφιλείς ιστότοποι", "search_sites": "Αναζήτηση ιστότοπων", + "view_all": "Προβολή όλων", "enable_basic_functionality": "Ενεργοποίηση βασικής λειτουργικότητας", "basic_functionality_disabled_title": "Η Εξερεύνηση δεν είναι διαθέσιμη", "basic_functionality_disabled_description": "Δεν είναι δυνατή η ανάκτηση των απαιτούμενων μεταδεδομένων όταν η βασική λειτουργικότητα είναι απενεργοποιημένη.", @@ -8199,6 +8276,7 @@ "perpetuals": "Συμβόλαια αορίστου διάρκειας", "predictions": "Προβλέψεις", "whats_happening": "Τι συμβαίνει", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Γεωπολιτικά", "macro": "Μακροοικονομικά", diff --git a/locales/languages/en.json b/locales/languages/en.json index 60622fdde3b..174b3df0201 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2101,7 +2101,13 @@ "title": "Market insights", "a_closer_look": "A closer look", "whats_being_said": "What's being said", + "card_footer_disclaimer": "AI generated", "footer_disclaimer": "AI summary for information only", + "disclaimer_modal": { + "title": "AI generated content", + "body": "This summary is AI-generated by a third party and may not be accurate or personalized. Don't use it for trading, investment, legal, or tax decisions. Past performance doesn't predict future results.", + "got_it": "Got it" + }, "swap_button": "Swap", "buy_button": "Buy", "sources_count": "+{{count}} sources", @@ -2391,6 +2397,7 @@ }, "game_details_footer": { "pick_a_winner": "Pick a winner", + "make_your_prediction": "Make your prediction", "volume_display": "${{volume}} Vol", "read_terms": "Read the full contract terms and conditions" }, diff --git a/locales/languages/es.json b/locales/languages/es.json index 48587ae1c02..568527860d9 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Acceso restringido", + "description_line1": "Esta dirección de billetera se señaló como sospechosa durante el proceso de verificación de cumplimiento. Por ello, algunos servicios de MetaMask no están disponibles.", + "description_line2": "Si crees que se trata de un error, comunícate con soporte para solicitar una revisión.", + "contact_support": "Contactar a soporte" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Se canceló la autenticación biométrica", "biometric_authentication_cancelled_title": "Configuración biométrica fallida", "biometric_authentication_cancelled_description": "Vuelve a configurar la autenticación biométrica desde la configuración.", - "biometric_authentication_cancelled_button": "Confirmar" + "biometric_authentication_cancelled_button": "Confirmar", + "biometric_changed": "Datos biométricos modificados", + "biometric_changed_alert_desc": "Se modificaron tus datos biométricos. Vuelve a habilitar la autenticación biométrica desde la configuración.", + "biometric_changed_alert_confirm": "Confirmar" }, "connect_hardware": { "title_select_hardware": "Conectar un monedero físico", @@ -1008,6 +1011,11 @@ "see_more": "Ver más" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Perps no está disponible", "title": "Perps", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "El límite de pérdidas debe estar por {{direction}} del precio {{priceType}}", "stop_loss_beyond_liquidation_error": "El límite de pérdidas debe estar por {{direction}} del precio de liquidación", "stop_loss_order_view_warning": "El límite de pérdidas está por {{direction}} del precio de liquidación", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Toma de ganancias debe estar por {{direction}} del precio de {{priceType}}. Actualiza o borra este valor para realizar la orden.", + "stop_loss_wrong_side_warning": "Límite de pérdidas debe estar por {{direction}} del precio de {{priceType}}. Actualiza o borra este valor para realizar la orden.", "above": "por encima", "below": "por debajo", "done": "Hecho", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Estimación de {{time}} segundos", "placing_prediction": "Realizar una predicción", "prediction_placed": "Predicción colocada", + "prediction_failed": "Failed to place prediction", "order_failed": "Orden fallida", "payments_made_in_usdc": "Todos los pagos se realizan en USDC", "prediction_insufficient_funds": "Fondos insuficientes. Puedes usar hasta {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Orden fallida", "order_failed_body": "No había suficiente liquidez a este precio. ¿Quieres volver a intentarlo?", "try_again": "Inténtalo de nuevo", + "view": "Ver", "yes_buy": "Sí, compra", "yes_sell": "Sí, vende" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Ocurrió un error desconocido", "order_not_fully_filled": "Error al completar tu orden", "buy_order_not_fully_filled": "No hay suficientes acciones disponibles al precio de mercado para colocar tu orden en este momento.", - "sell_order_not_fully_filled": "No hay suficiente demanda al precio de mercado para cobrar en este momento." + "sell_order_not_fully_filled": "No hay suficiente demanda al precio de mercado para cobrar en este momento.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Elige un ganador", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Predicciones financiadas", "tx_review_predict_claim": "Victorias reclamadas", "tx_review_predict_withdraw": "Predicciones retiradas", + "tx_review_perps_withdraw": "Retiro de contratos perpetuos", "tx_review_musd_conversion": "Conversión de mUSD", "claim": "Reclamar", "sent_ether": "ETH enviado", @@ -5992,6 +6004,7 @@ "percentage_bonus": "Bonificación del {{percentage}} %", "claimable_bonus": "Bonificación reclamable", "claim_bonus": "Reclamar bono", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "El bono se pagará en {{networkName}}.", "percentage_bonus_on_linea": "Bono del {{percentage}} % en Linea", "claim": "Reclamar", @@ -6152,53 +6165,53 @@ }, "money": { "title": "Money", - "apy_label": "{{percentage}}% APY", + "apy_label": "{{percentage}} % de APY", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Agregar", + "transfer": "Transferir", + "card": "Tarjeta" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Tu posición", + "current_rate": "Tasa actual", + "lifetime_earnings": "Ganancias totales", + "available_balance": "Saldo disponible" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Cómo funciona", + "description": "Mantén mUSD en una cuenta Money y gana automáticamente. Es un activo respaldado por el dólar, siempre líquido y disponible para gastar, operar o enviar en cualquier momento.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Agregar" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Ganancias potenciales", + "amount": "+$26.800", + "description": "Descubre cómo tu dinero puede crecer con el tiempo al convertir tus criptomonedas a mUSD.", + "convert": "Convertir", + "no_fee": "Sin tarifa", + "see_earnings": "Ver ganancias potenciales" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "Tarjeta MetaMask", + "subtitle": "Gasta tu dinero en cualquier lugar.", + "virtual_card": "Tarjeta Virtual", + "metal_card": "Tarjeta Metal", + "cashback": "{{percentage}} % de cashback", + "get_now": "Obtener ahora" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "¿Por qué MetaMask Money?", + "benefit_auto_earn": "Ganancias automáticas ", + "benefit_dollar_backed": "Tu dinero se mantiene en mUSD, una moneda estable respaldada por el dólar en una proporción de 1:1", + "benefit_liquidity": "Liquidez total sin bloqueos, para que puedas operar o retirar fondos en cualquier momento", + "benefit_spend_prefix": "Gasta en más de 150 millones de comercios con la tarjeta MetaMask y gana ", + "benefit_spend_cashback": "1-3 % de cashback", + "benefit_global": "Envía y recibe dinero a nivel mundial sin intermediarios", + "learn_more": "Conozca más" }, "footer": { - "add_money": "Add money" + "add_money": "Agregar dinero" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Actualización de la cuenta", "approve": "Aprobar la solicitud", "perps_deposit": "Agregar fondos", + "perps_withdraw": "Retirar", "predict_deposit": "Añadir fondos de Predicciones", "predict_withdraw": "Retirar", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Este sitio necesita permiso para gastar sus tokens.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "{{index}} de transacción", "transaction": "Transacción", "available_balance": "Saldo disponible: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Saldo disponible de contratos perpetuos: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Agregar fondos", "deposit_edit_amount_predict_withdraw": "Retirar", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Inténtalo de nuevo", "no_internet_connection_title": "No se puede conectar", "no_internet_connection_description": "Tu conexión a Internet es inestable. Revisa tu conexión e inténtalo de nuevo.", - "no_internet_connection_button": "Inténtalo de nuevo" + "no_internet_connection_button": "Inténtalo de nuevo", + "ios_need_update_title": "Se requiere actualización de iOS", + "ios_need_update_description": "El inicio de sesión con Google en MetaMask pronto requerirá ", + "ios_need_update_description_version": "iOS 17.4 o posterior", + "ios_need_update_description_end": ". Por ahora, puedes seguir usando el inicio de sesión de Google en este dispositivo, pero dejará de ser compatible en una próxima actualización.", + "ios_need_update_description2": "Aún puedes acceder a tu billetera usando la misma cuenta de Google en un dispositivo compatible o mediante la extensión MetaMask. Te recomendamos encarecidamente que crees una copia de seguridad de tu frase secreta de recuperación para garantizar un acceso continuo.", + "ios_need_update_button": "Continuar" }, "password_hint": { "title": "Pista de contraseña", @@ -7430,7 +7450,9 @@ "loading": "Cargando tokens disponibles...", "load_error": "No se pueden cargar los tokens. Inténtalo de nuevo.", "retry": "Inténtalo de nuevo", - "on_linea": "en Linea" + "on_linea": "en Linea", + "account_label": "Cuenta", + "token_label": "Token" }, "cashback_screen": { "title": "Cashback", @@ -7881,6 +7903,7 @@ "daily_bonus": "Bono diario reclamable", "annualized_bonus": "Bono anualizado", "disclaimer": "Esto es solo una estimación. El bono está sujeto a cambios.", + "disclaimer_brief": "El bono es una estimación y puede variar.", "buy_button": "Comprar mUSD", "swap_button": "Canjear por mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Empieza el {{date}}", "ends_date": "Termina el {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "A continuación", + "ended_date": "Finalizó el {{date}}", + "pill_up_next": "Próximamente", "pill_active": "En vivo", "pill_complete": "Completado", "enter_now": "Participa ahora", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Términos de uso complementarios y aviso de privacidad", "opt_in_sheet_description_post_link": "Realizaremos un seguimiento de la actividad en la cadena para recompensarte automáticamente.", "geo_restriction_banner_title": "No está disponible en tu región", - "geo_restriction_banner_description": "Esta campaña no está disponible en tu región debido a regulaciones locales." + "geo_restriction_banner_description": "Esta campaña no está disponible en tu región debido a regulaciones locales.", + "opt_in_success_toast": "¡Estás dentro!" }, "campaign_mechanics": { "title": "Mecánica" @@ -7945,10 +7969,60 @@ "opted_in": "Ya estás participando en esta campaña", "opt_in_error": "Error al activar la participación. Inténtalo de nuevo.", "join_campaign": "Únete a la campaña", + "entries_closed_title": "Inscripciones cerradas", + "entries_closed_description": "Se te pasó el período de inscripción", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Comprobando estado de participación", "swap": "Canjear", "how_it_works": "Cómo funciona" }, + "ondo_campaign_leaderboard_position": { + "title": "Tu posición", + "rank": "Mi clasificación", + "tier": "Mi nivel", + "rate_of_return": "Rendimiento", + "total_deposited": "Total depositado", + "current_value": "Valor actual", + "not_found": "Aún no apareces en la tabla de clasificación. Vuelve a consultar más tarde.", + "updated_at": "Última actualización: {{time}}", + "error_loading": "Error al cargar tu posición", + "error_loading_description": "Se produjo un error al cargar tu posición en la clasificación. Inténtalo de nuevo.", + "retry": "Reintentar" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Valor total", + "portfolio_pnl": "P&L", + "portfolio_pnl_percent": "P&L (%)", + "summary_cost_basis": "Costo base", + "summary_net_deposit": "Depósito neto", + "position_current_value": "Valor", + "position_unrealized_pnl": "P&L", + "updated_at": "Última actualización: {{time}}", + "error_loading": "Error al cargar las posiciones.", + "error_loading_description": "Se produjo un error al cargar tus posiciones. Inténtalo de nuevo.", + "retry": "Reintentar", + "loading": "Cargando posiciones...", + "empty": "Aún no se encontraron posiciones.", + "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" + }, + "ondo_campaign_leaderboard": { + "title": "Clasificación", + "your_position": "Tu posición", + "of_total": "de {{total}} participantes", + "total_participants": "{{count}} participantes", + "updated_at": "Última actualización: {{time}}", + "error_loading": "Error al cargar la clasificación", + "error_loading_description": "Se produjo un error al cargar la clasificación. Inténtalo de nuevo.", + "error_loading_position": "Error al cargar tu posición", + "retry": "Reintentar", + "no_data": "No hay datos de clasificación disponibles", + "no_entries_in_tier": "Aún no hay participantes en este nivel", + "not_yet_computed": "Aún no se ha calculado la clasificación. Vuelve más tarde." + }, "campaigns_preview": { "title": "Campañas", "coming_soon": "Próximamente", @@ -7983,6 +8057,7 @@ "musd_conversion": "Convertidas a mUSD", "musd_claim": "mUSD reclamados", "perps_deposit": "Cuenta financiada de perps", + "perps_withdraw": "Retiro", "predict_claim": "Ganancias reclamadas", "predict_deposit": "Cuenta de Predict financiada", "predict_withdraw": "Retiro", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceSymbol}} enviado(s) desde {{sourceChain}}", "musd_claim": "Reclamar mUSD", "perps_deposit": "Agregar fondos", + "perps_withdraw": "Retiro", "predict_deposit": "Agregar fondos", "swap": "Canjear tokens", "swap_approval": "Aprobar tokens" @@ -8082,6 +8158,7 @@ "sites": "Sitios", "popular_sites": "Sitios populares", "search_sites": "Sitios de búsqueda", + "view_all": "Ver todo", "enable_basic_functionality": "Activar la funcionalidad básica", "basic_functionality_disabled_title": "Explorar no está disponible", "basic_functionality_disabled_description": "No podemos obtener los metadatos necesarios cuando la funcionalidad básica está deshabilitada.", @@ -8199,6 +8276,7 @@ "perpetuals": "Contratos perpetuos", "predictions": "Predicciones", "whats_happening": "Qué está pasando", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolítico", "macro": "Macro", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 86c81c80e14..08d2f8cb1fa 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Accès restreint", + "description_line1": "Cette adresse de portefeuille a été signalée lors du contrôle de conformité. Par conséquent, certains services MetaMask ne sont pas disponibles.", + "description_line2": "Si vous pensez qu’il s’agit d’une erreur, contactez le service d’assistance pour demander une réévaluation.", + "contact_support": "Contacter le service d’assistance" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Authentification biométrique annulée", "biometric_authentication_cancelled_title": "Échec de la configuration biométrique", "biometric_authentication_cancelled_description": "Veuillez reconfigurer l’authentification biométrique à partir des paramètres.", - "biometric_authentication_cancelled_button": "Confirmer" + "biometric_authentication_cancelled_button": "Confirmer", + "biometric_changed": "Données biométriques modifiées", + "biometric_changed_alert_desc": "Vos données biométriques ont été modifiées. Veuillez réactiver l’authentification biométrique dans les paramètres.", + "biometric_changed_alert_confirm": "Confirmer" }, "connect_hardware": { "title_select_hardware": "Connecter un portefeuille matériel", @@ -1008,6 +1011,11 @@ "see_more": "Afficher plus" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Contrats à terme perpétuels non disponibles", "title": "Perps", @@ -1258,8 +1266,8 @@ "tp_sl": "Fermeture automatique", "tp": "TP", "sl": "SL", - "long_label": "Acheter", - "short_label": "Vendre", + "long_label": "Long", + "short_label": "Courte", "button": { "long": "Acheter {{asset}}", "short": "Vendre {{asset}}" @@ -1363,7 +1371,7 @@ "trigger_condition": "Condition d’exécution", "price": "Prix", "fee": "Frais", - "limit_buy": "Ordre d’achat à cours limité", + "limit_buy": "Long limite", "limit_price": "Prix limite", "limit_sell": "Ordre de vente à cours limité", "market_buy": "Ordre d’achat au marché", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Le stop loss doit être {{direction}} au prix {{priceType}}", "stop_loss_beyond_liquidation_error": "Le stop loss doit être {{direction}} au prix de liquidation", "stop_loss_order_view_warning": "Le stop loss est {{direction}} au prix de liquidation", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Le take profit doit être {{direction}} au prix {{priceType}}. Mettez-le à jour ou supprimez-le pour passer l’ordre de bourse.", + "stop_loss_wrong_side_warning": "Le stop loss doit être {{direction}} au prix {{priceType}}. Mettez-le à jour ou supprimez-le pour passer l’ordre de bourse.", "above": "supérieur", "below": "inférieur", "done": "Terminé", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Dans environ {{time}} secondes", "placing_prediction": "Placement d’une prédiction", "prediction_placed": "Prédiction placée", + "prediction_failed": "Failed to place prediction", "order_failed": "La commande a échoué", "payments_made_in_usdc": "Tous les paiements sont effectués en USDC", "prediction_insufficient_funds": "Fonds insuffisants. Vous pouvez utiliser jusqu’à {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "L’exécution de l’ordre a échoué", "order_failed_body": "Il n’y avait pas assez de liquidité à ce prix. Voulez-vous réessayer ?", "try_again": "Réessayer", + "view": "Afficher", "yes_buy": "Oui, acheter", "yes_sell": "Oui, vendre" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Une erreur inconnue est survenue", "order_not_fully_filled": "Votre ordre n’a pas pu être exécuté", "buy_order_not_fully_filled": "Il n’y a actuellement pas suffisamment d’actions disponibles au prix du marché pour passer votre ordre.", - "sell_order_not_fully_filled": "Il n’y a actuellement pas assez de demande au prix du marché pour effectuer un retrait." + "sell_order_not_fully_filled": "Il n’y a actuellement pas assez de demande au prix du marché pour effectuer un retrait.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Choisissez un gagnant", @@ -3483,8 +3494,8 @@ "send_button": "Envoyer", "buy_button": "Acheter", "cash_buy_button": "Achat au comptant", - "long_button": "Acheter", - "short_button": "Vendre", + "long_button": "Long", + "short_button": "Court", "more_button": "Plus", "token_marketplace": "Marché des jetons", "sell_button": "Vendre", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Prédictions financées", "tx_review_predict_claim": "Gains réclamés", "tx_review_predict_withdraw": "Retrait des prédictions", + "tx_review_perps_withdraw": "Retrait du compte de trading de contrats à terme perpétuels", "tx_review_musd_conversion": "Conversion en mUSD", "claim": "Réclamer", "sent_ether": "ETH envoyé(s)", @@ -5992,6 +6004,7 @@ "percentage_bonus": "Bonus de {{percentage}} %", "claimable_bonus": "Bonus réclamable", "claim_bonus": "Réclamer le bonus", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Le bonus sera versé sur {{networkName}}.", "percentage_bonus_on_linea": "Bonus de {{percentage}} % sur Linea", "claim": "Réclamer", @@ -6152,53 +6165,53 @@ }, "money": { "title": "Money", - "apy_label": "{{percentage}}% APY", + "apy_label": "Taux de rendement annuel de {{percentage}} %", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Ajouter", + "transfer": "Transférer", + "card": "Carte" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Votre position", + "current_rate": "Taux actuel", + "lifetime_earnings": "Gains cumulés", + "available_balance": "Solde disponible" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Comment ça marche ", + "description": "Conservez des mUSD sur un compte « Money » et percevez automatiquement des revenus. Les mUSD sont adossés au dollar, toujours liquides et peuvent être dépensés, échangés ou envoyés à tout moment.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Ajouter" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Gains potentiels", + "amount": "+26 800 $", + "description": "Découvrez comment votre argent peut fructifier au fil du temps en convertissant vos crypto-monnaies en mUSD.", + "convert": "Convertir", + "no_fee": "Pas de frais", + "see_earnings": "Voir les gains potentiels" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMask Card", + "subtitle": "Dépensez votre argent où vous voulez.", + "virtual_card": "Carte virtuelle", + "metal_card": "Carte Metal", + "cashback": "{{percentage}} % de cashback", + "get_now": "Obtenir maintenant" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Pourquoi utiliser MetaMask Money ?", + "benefit_auto_earn": "Percevez automatiquement des revenus ", + "benefit_dollar_backed": "Votre argent est détenu en mUSD, un stablecoin adossé au dollar à un ratio de 1:1", + "benefit_liquidity": "Liquidité totale sans période de blocage, pour que vous puissiez trader ou retirer des fonds à tout moment", + "benefit_spend_prefix": "Effectuez vos achats chez plus de 150 millions de commerçants avec la MetaMask Card et bénéficiez de ", + "benefit_spend_cashback": "1 à 3 % de cashback", + "benefit_global": "Envoyez et recevez de l’argent dans le monde entier sans intermédiaire", + "learn_more": "En savoir plus" }, "footer": { - "add_money": "Add money" + "add_money": "Ajouter de l’argent" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Mise à jour du compte", "approve": "Approuver la demande", "perps_deposit": "Ajouter des fonds", + "perps_withdraw": "Retirer", "predict_deposit": "Ajouter des fonds de prédiction", "predict_withdraw": "Retirer", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Ce site demande l’autorisation de dépenser vos jetons.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Transaction {{index}}", "transaction": "Protection des", "available_balance": "Solde disponible: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Solde disponible sur le compte de trading de contrats à terme perpétuels : ", "edit_amount_done": "Continuer", "deposit_edit_amount_done": "Ajouter des fonds", "deposit_edit_amount_predict_withdraw": "Retirer", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Réessayez", "no_internet_connection_title": "Impossible de se connecter", "no_internet_connection_description": "Votre connexion Internet est instable. Vérifiez votre connexion Internet et réessayez.", - "no_internet_connection_button": "Réessayer" + "no_internet_connection_button": "Réessayer", + "ios_need_update_title": "Mise à jour du système d’exploitation iOS requise", + "ios_need_update_description": "La connexion à MetaMask via Google nécessitera bientôt ", + "ios_need_update_description_version": "iOS 17.4 ou une version ultérieure", + "ios_need_update_description_end": ". Pour le moment, vous pourrez continuer à utiliser la connexion Google sur cet appareil, mais cette fonctionnalité ne sera plus prise en charge après la prochaine mise à jour.", + "ios_need_update_description2": "Vous pouvez toujours accéder à votre portefeuille en utilisant le même compte Google sur un appareil compatible ou via l’extension MetaMask. Nous vous recommandons vivement de sauvegarder votre phrase secrète de récupération pour éviter toute perte d’accès à votre portefeuille.", + "ios_need_update_button": "Continuer" }, "password_hint": { "title": "Indice de mot de passe", @@ -7430,7 +7450,9 @@ "loading": "Chargement des jetons disponibles…", "load_error": "Impossible de charger les jetons. Veuillez réessayer.", "retry": "Réessayez", - "on_linea": "sur Linea" + "on_linea": "sur Linea", + "account_label": "Compte", + "token_label": "Jeton" }, "cashback_screen": { "title": "Cashback", @@ -7881,6 +7903,7 @@ "daily_bonus": "Bonus quotidien pouvant être réclamé", "annualized_bonus": "Bonus annualisé", "disclaimer": "Il ne s’agit que d’une estimation. Le montant du bonus peut varier.", + "disclaimer_brief": "Le bonus est une estimation et peut varier.", "buy_button": "Acheter des mUSD", "swap_button": "Échanger en mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Débute le {{date}}", "ends_date": "Se termine le {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "À venir", + "ended_date": "A pris fin le {{date}}", + "pill_up_next": "Bientôt disponible", "pill_active": "Active", "pill_complete": "Terminé", "enter_now": "S’inscrire maintenant", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Conditions d’utilisation supplémentaires et avis de confidentialité de MetaMask Rewards", "opt_in_sheet_description_post_link": "Nous suivrons votre activité sur la chaîne pour vous récompenser automatiquement.", "geo_restriction_banner_title": "Non disponible dans votre région", - "geo_restriction_banner_description": "Cette campagne n’est pas disponible dans votre région en raison de la réglementation locale." + "geo_restriction_banner_description": "Cette campagne n’est pas disponible dans votre région en raison de la réglementation locale.", + "opt_in_success_toast": "Vous êtes inscrit !" }, "campaign_mechanics": { "title": "Déroulement" @@ -7945,10 +7969,60 @@ "opted_in": "Vous êtes inscrit à cette campagne", "opt_in_error": "Échec de l’inscription. Veuillez réessayer.", "join_campaign": "Rejoindre la campagne", + "entries_closed_title": "Période d’inscription terminée", + "entries_closed_description": "Vous avez manqué votre chance, la période d’inscription est terminée", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Vérification du statut d’inscription", "swap": "Échanger", "how_it_works": "Comment ça marche " }, + "ondo_campaign_leaderboard_position": { + "title": "Votre position", + "rank": "Mon classement", + "tier": "Mon niveau", + "rate_of_return": "Rendement", + "total_deposited": "Montant total déposé", + "current_value": "Valeur actuelle", + "not_found": "Vous ne figurez pas encore dans le classement. Veuillez revenir plus tard.", + "updated_at": "Dernière mise à jour : {{time}}", + "error_loading": "Échec du chargement de votre classement", + "error_loading_description": "Une erreur s’est produite lors du chargement de votre position au classement. Veuillez réessayer.", + "retry": "Réessayer" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Valeur totale", + "portfolio_pnl": "Résultat", + "portfolio_pnl_percent": "Résultat (%)", + "summary_cost_basis": "Coût de base", + "summary_net_deposit": "Dépôt net", + "position_current_value": "Valeur", + "position_unrealized_pnl": "Résultat", + "updated_at": "Dernière mise à jour : {{time}}", + "error_loading": "Échec du chargement des positions.", + "error_loading_description": "Une erreur s’est produite lors du chargement de vos positions. Veuillez réessayer.", + "retry": "Réessayer", + "loading": "Chargement des positions…", + "empty": "Aucune position trouvée pour le moment.", + "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" + }, + "ondo_campaign_leaderboard": { + "title": "Classement", + "your_position": "Votre position", + "of_total": "sur {{total}} participants", + "total_participants": "{{count}} participants", + "updated_at": "Dernière mise à jour : {{time}}", + "error_loading": "Échec du chargement du classement", + "error_loading_description": "Une erreur s’est produite lors du chargement du classement. Veuillez réessayer.", + "error_loading_position": "Échec du chargement de votre position", + "retry": "Réessayer", + "no_data": "Aucune donnée disponible sur le classement", + "no_entries_in_tier": "Aucun participant dans ce niveau pour l’instant", + "not_yet_computed": "Le classement n’a pas encore été calculé. Revenez bientôt." + }, "campaigns_preview": { "title": "Campagnes", "coming_soon": "Bientôt disponible", @@ -7983,6 +8057,7 @@ "musd_conversion": "Converti en mUSD", "musd_claim": "mUSD réclamé", "perps_deposit": "Compte de trading de contrats à terme financé", + "perps_withdraw": "Retrait", "predict_claim": "Gains réclamés", "predict_deposit": "Compte « Predict » financé", "predict_withdraw": "Retrait", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceSymbol}} envoyé depuis {{sourceChain}}", "musd_claim": "Réclamer mes mUSD", "perps_deposit": "Ajouter des fonds", + "perps_withdraw": "Retrait", "predict_deposit": "Ajouter des fonds", "swap": "Échanger des jetons", "swap_approval": "Approuver les jetons" @@ -8082,6 +8158,7 @@ "sites": "Sites", "popular_sites": "Sites populaires", "search_sites": "Rechercher des sites", + "view_all": "Tout afficher", "enable_basic_functionality": "Activer les fonctionnalités de base", "basic_functionality_disabled_title": "La section « Explorer » n’est pas disponible", "basic_functionality_disabled_description": "Nous ne pouvons pas récupérer les métadonnées requises lorsque les fonctionnalités de base sont désactivées.", @@ -8199,6 +8276,7 @@ "perpetuals": "Contrats perpétuels", "predictions": "Prédictions", "whats_happening": "Actualités", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Géopolitique", "macro": "Macroéconomie", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index bcc5ae1017e..6daf545e03e 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "एक्सेस प्रतिबंधित", + "description_line1": "कम्प्लायंस स्क्रीनिंग के दौरान इस वॉलेट एड्रेस की शिक़ायत मिली है। इस वजह से, कुछ MetaMask सर्विस उपलब्ध नहीं हैं।", + "description_line2": "अगर आपको लगता है कि यह एक गड़बड़ी है, तो रिव्यू के लिए सपोर्ट से संपर्क करें।", + "contact_support": "सपोर्ट टीम से कॉन्टेक्ट करें" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "बायोमेट्रिक ऑथेंटिकेशन कैंसिल हो गया", "biometric_authentication_cancelled_title": "बायोमेट्रिक सेटअप नहीं हो पाया", "biometric_authentication_cancelled_description": "कृपया सेटिंग्स से बायोमेट्रिक ऑथेंटिकेशन को री-सेटअप करें।", - "biometric_authentication_cancelled_button": "कन्फर्म करें" + "biometric_authentication_cancelled_button": "कन्फर्म करें", + "biometric_changed": "बायोमेट्रिक बदला गया", + "biometric_changed_alert_desc": "आपका बायोमेट्रिक बदल दिया गया है। कृपया सेटिंग्स से बायोमेट्रिक को फिर से चालू करें।", + "biometric_changed_alert_confirm": "कन्फर्म करें" }, "connect_hardware": { "title_select_hardware": "किसी hardware wallet को कनेक्ट करें", @@ -1008,6 +1011,11 @@ "see_more": "अधिक देखें" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "पर्प्स उपलब्ध नहीं है", "title": "पर्प्स", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "स्टॉप लॉस {{direction}} {{priceType}} प्राइस का होना चाहिए", "stop_loss_beyond_liquidation_error": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का होना चाहिए", "stop_loss_order_view_warning": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का है", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "प्रॉफ़िट {{direction}} {{priceType}} प्राइस का होना चाहिए। ऑर्डर देने के लिए इसे अपडेट करें या हटाएं।", + "stop_loss_wrong_side_warning": "स्टॉप लॉस {{direction}} {{priceType}} प्राइस का होना चाहिए। ऑर्डर देने के लिए इसे अपडेट करें या हटाएं।", "above": "ऊपर", "below": "नीचे", "done": "पूरा हुआ", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "अनुमानित {{time}} सेकंड", "placing_prediction": "प्रेडिक्शन लगाया जा रहा है", "prediction_placed": "प्रेडिक्शन लगाया गया", + "prediction_failed": "Failed to place prediction", "order_failed": "ऑर्डर नहीं हो पाया", "payments_made_in_usdc": "सभी पेमेंट USDC में किए जाते हैं", "prediction_insufficient_funds": "फंड पर्याप्त नहीं हैं। आप {{amount}} तक का उपयोग कर सकते हैं।", @@ -2322,6 +2331,7 @@ "order_failed_title": "ऑर्डर नहीं हो पाया", "order_failed_body": "इस कीमत पर लिक्विडिटी काफ़ी नहीं थी। फिर से कोशिश करना चाहते हैं?", "try_again": "फिर से कोशिश करें", + "view": "देखें", "yes_buy": "हाँ, खरीदें", "yes_sell": "हाँ, बेचें" }, @@ -2376,7 +2386,8 @@ "unknown_error": "एक अज्ञात गड़बड़ी हुई", "order_not_fully_filled": "आपका ऑर्डर पूरा नहीं हो पाया", "buy_order_not_fully_filled": "मार्केट प्राइस पर अभी आपका ऑर्डर लगाने के लिए पर्याप्त शेयर उपलब्ध नहीं हैं।", - "sell_order_not_fully_filled": "मार्केट प्राइस पर अभी कैश आउट करने के लिए पर्याप्त माँग नहीं है।" + "sell_order_not_fully_filled": "मार्केट प्राइस पर अभी कैश आउट करने के लिए पर्याप्त माँग नहीं है।", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "एक विजेता चुनें", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "फ़ंड किए गए प्रेडिक्शन", "tx_review_predict_claim": "क्लेम किया गया जीत का ईनाम", "tx_review_predict_withdraw": "प्रेडिक्शन विदड्रॉ", + "tx_review_perps_withdraw": "पर्प्स विदड्रॉ", "tx_review_musd_conversion": "mUSD कन्वर्शन", "claim": "क्लेम करें", "sent_ether": "ETH भेजा", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}% बोनस", "claimable_bonus": "क्लेम करने योग्य बोनस", "claim_bonus": "बोनस क्लेम करें", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "बोनस {{networkName}} पर दिया जाएगा।", "percentage_bonus_on_linea": "Linea पर {{percentage}}% बोनस", "claim": "क्लेम करें", @@ -6154,51 +6167,51 @@ "title": "Money", "apy_label": "{{percentage}}% APY", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "जोड़ें", + "transfer": "स्थानांतरण", + "card": "कार्ड" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "आपकी पोज़िशन", + "current_rate": "वर्तमान रेट", + "lifetime_earnings": "जीवन भर की कमाई", + "available_balance": "उपलब्ध बैलेंस" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "ये कैसे काम करता है", + "description": "mUSD को Money अकाउंट में रखें और अपने आप कमाएँ। यह डॉलर-बैक्ड है, हमेशा लिक्विड रहता है, और कभी भी खर्च करने, ट्रेड करने या भेजने के लिए तैयार है।", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "जोड़ें" }, "potential_earnings": { - "title": "Potential earnings", + "title": "संभावित कमाई", "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "description": "देखें कि अपने क्रिप्टो को mUSD में बदलकर आपका पैसा समय के साथ कैसे बढ़ सकता है।", + "convert": "कन्वर्ट करें", + "no_fee": "कोई शुल्क नहीं", + "see_earnings": "संभावित कमाई देखें" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMask कार्ड", + "subtitle": "अपना पैसा कहीं भी खर्च करें।", + "virtual_card": "वर्चुअल कार्ड", + "metal_card": "मेटल कार्ड", + "cashback": "{{percentage}}% कैशबैक", + "get_now": "अभी पाएं" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "MetaMask Money क्यों?", + "benefit_auto_earn": "ऑटो-अर्न ", + "benefit_dollar_backed": "आपका पैसा mUSD में रखा जाता है, जो एक 1:1 डॉलर-बैक्ड स्टेबलकॉइन है", + "benefit_liquidity": "बिना किसी लॉकअप के पूरी लिक्विडिटी, इसलिए आप कभी भी ट्रेड कर सकते हैं या निकाल सकते हैं", + "benefit_spend_prefix": "MetaMask कार्ड के साथ 150M+ मर्चेंट पर खर्च करें और कमाएँ ", + "benefit_spend_cashback": "1-3% कैशबैक", + "benefit_global": "बिना किसी बिचौलिए के दुनिया भर में पैसे भेजें और प्राप्त करें", + "learn_more": "ज़्यादा जानें" }, "footer": { - "add_money": "Add money" + "add_money": "धन जोड़ें" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "अकाउंट अपडेट", "approve": "अनुरोध एप्रूव करें", "perps_deposit": "फंड जोड़ें", + "perps_withdraw": "निकालें", "predict_deposit": "प्रिडिक्शन फ़ंड जोड़ें", "predict_withdraw": "निकालें", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "यह साइट आपके टोकन खर्च करने की अनुमति चाहती है।", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "ट्रांसेक्शन {{index}}", "transaction": "ट्रांसेक्शन", "available_balance": "उपलब्ध बैलेंस: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "उपलब्ध पर्प्स बैलेंस: ", "edit_amount_done": "जारी रखें", "deposit_edit_amount_done": "फंड जोड़ें", "deposit_edit_amount_predict_withdraw": "निकालें", @@ -6810,7 +6824,13 @@ "oauth_error_button": "फिर से कोशिश करें", "no_internet_connection_title": "कनेक्ट करने में असमर्थ", "no_internet_connection_description": "आपका इंटरनेट कनेक्शन अस्थिर है। अपना कनेक्शन जांचें और फिर से प्रयास करें।", - "no_internet_connection_button": "फिर से प्रयास करें" + "no_internet_connection_button": "फिर से प्रयास करें", + "ios_need_update_title": "iOS अपडेट ज़रूरी है", + "ios_need_update_description": "MetaMask Google Sign-In के लिए जल्द ही ", + "ios_need_update_description_version": "iOS 17.4 या बाद का वर्शन ज़रूरी होगा", + "ios_need_update_description_end": "। आप अभी इस डिवाइस पर Google Sign-In का इस्तेमाल जारी रख सकते हैं, लेकिन आने वाले अपडेट में यह सपोर्ट नहीं करेगा।", + "ios_need_update_description2": "आप अभी भी सपोर्टेड डिवाइस या MetaMask एक्सटेंशन पर उसी Google अकाउंट का इस्तेमाल करके अपने वॉलेट को एक्सेस कर सकते हैं। हम आपको सलाह देते हैं कि आप अपने सीक्रेट रिकवरी फ्रेज़ का बैकअप ले लें ताकि बिना किसी रुकावट के एक्सेस मिल सके।", + "ios_need_update_button": "जारी रखें" }, "password_hint": { "title": "पासवर्ड संकेत", @@ -7430,7 +7450,9 @@ "loading": "उपलब्ध टोकन लोड हो रहे हैं…", "load_error": "टोकन लोड नहीं हो पाए। कृपया फिर से प्रयास करें।", "retry": "फिर से प्रयास करें", - "on_linea": "Linea पर" + "on_linea": "Linea पर", + "account_label": "अकाउंट", + "token_label": "टोकन" }, "cashback_screen": { "title": "कैशबैक", @@ -7881,6 +7903,7 @@ "daily_bonus": "रोज़ाना क्लेम किया जाने वाला बोनस", "annualized_bonus": "सालाना बोनस", "disclaimer": "यह सिर्फ़ एक अनुमान है। बोनस बदल सकता है।", + "disclaimer_brief": "बोनस एक अनुमान है और बदल सकता है।", "buy_button": "mUSD खरीदें", "swap_button": "mUSD पर स्वैप करें" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "{{date}} को शुरू होता है", "ends_date": "{{date}} को समाप्त होता है", - "ended_date": "Ended {{date}}", - "pill_up_next": "आगे आने वाला है", + "ended_date": "{{date}} को समाप्त हुआ", + "pill_up_next": "जल्द आ रहा है", "pill_active": "लाइव", "pill_complete": "पूरा", "enter_now": "अभी एंटर करें", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "के पूरक उपयोग की शर्तों और गोपनीयता नोटिस से सहमत होते हैं", "opt_in_sheet_description_post_link": "हम आपको ऑटोमैटिकली रिवॉर्ड देने के लिए ऑनचेन एक्टिविटी को ट्रैक करेंगे।", "geo_restriction_banner_title": "आपके इलाके में उपलब्ध नहीं है", - "geo_restriction_banner_description": "लोकल नियमों के कारण यह कैंपेन आपके इलाके में उपलब्ध नहीं है।" + "geo_restriction_banner_description": "लोकल नियमों के कारण यह कैंपेन आपके इलाके में उपलब्ध नहीं है।", + "opt_in_success_toast": "आप तैयार हैं!" }, "campaign_mechanics": { "title": "मैकेनिक्स" @@ -7945,10 +7969,60 @@ "opted_in": "आपने इस कैंपेन में ऑप्ट इन किया है", "opt_in_error": "ऑप्ट इन करना नहीं हो पाया। कृपया फिर से प्रयास करें।", "join_campaign": "कैंपेन जॉइन करें", + "entries_closed_title": "एंट्री बंद हो गई हैं", + "entries_closed_description": "आप ऑप्ट-इन विंडो से चूक गए", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "ऑप्ट इन स्टेटस चेक किया जा रहा है", "swap": "स्वैप करें", "how_it_works": "ये कैसे काम करता है" }, + "ondo_campaign_leaderboard_position": { + "title": "आपकी पोज़िशन", + "rank": "मेरी रैंक", + "tier": "मेरा टियर", + "rate_of_return": "कमाई", + "total_deposited": "कुल डिपॉज़िट किया गया", + "current_value": "वर्तमान वैल्यू", + "not_found": "अभी लीडरबोर्ड पर नहीं है। कृपया बाद में फिर से देखें।", + "updated_at": "पिछला अपडेट इस समय पर किया गया: {{time}}", + "error_loading": "आपकी पोज़िशन लोड नहीं हो पाई", + "error_loading_description": "आपकी लीडरबोर्ड पोज़िशन लोड करते समय एक गड़बड़ी हुई। कृपया फिर से कोशिश करें।", + "retry": "फिर से प्रयास करें" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "कुल वैल्यू", + "portfolio_pnl": "पी एंड एल", + "portfolio_pnl_percent": "पी एंड एल (%)", + "summary_cost_basis": "कॉस्ट के आधार पर", + "summary_net_deposit": "नेट डिपॉज़िट", + "position_current_value": "वैल्यू", + "position_unrealized_pnl": "पी एंड एल", + "updated_at": "पिछला अपडेट इस समय पर किया गया: {{time}}", + "error_loading": "पोज़िशन लोड नहीं हो पाई।", + "error_loading_description": "आपकी पोज़िशन्स लोड करते समय एक गड़बड़ी हुई। कृपया फिर से कोशिश करें।", + "retry": "फिर से प्रयास करें", + "loading": "पोजीशन्स लोड हो रही है...", + "empty": "अभी तक कोई पोज़िशन नहीं मिली।", + "empty_description": "टोकन वाले रियल-वर्ल्ड एसेट्स में पोज़िशन खोलकर रिवॉर्ड कमाना शुरू करें।", + "empty_cta": "एक पोज़िशन खोलें", + "position_units": "{{units}} शेयर" + }, + "ondo_campaign_leaderboard": { + "title": "लीडरबोर्ड", + "your_position": "आपकी पोज़िशन", + "of_total": "{{total}} प्रतिभागियों में से", + "total_participants": "{{count}} प्रतिभागी", + "updated_at": "पिछला अपडेट इस समय पर किया गया: {{time}}", + "error_loading": "लीडरबोर्ड लोड नहीं हो पाया", + "error_loading_description": "लीडरबोर्ड लोड करते समय कुछ गड़बड़ हो गई। कृपया फिर से कोशिश करें।", + "error_loading_position": "आपकी पोज़िशन लोड नहीं हो पाई", + "retry": "फिर से प्रयास करें", + "no_data": "कोई लीडरबोर्ड डेटा उपलब्ध नहीं है", + "no_entries_in_tier": "इस टियर में अभी तक कोई प्रतिभागी नहीं हैं", + "not_yet_computed": "लीडरबोर्ड अभी तक कंप्यूट नहीं किया गया है। जल्द ही फिर से देखें।" + }, "campaigns_preview": { "title": "कैंपेन", "coming_soon": "जल्द आ रहा है", @@ -7983,6 +8057,7 @@ "musd_conversion": "mUSD में कन्वर्ट किया गया", "musd_claim": "mUSD क्लेम किया गया", "perps_deposit": "फंडेड पर्प्स अकाउंट", + "perps_withdraw": "विदड्रॉवल", "predict_claim": "क्लेम किया गया जीत का ईनाम", "predict_deposit": "फ़ंड किया गया प्रेडिक्ट अकाउंट", "predict_withdraw": "विदड्रॉवल", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceSymbol}} को {{sourceChain}} से भेजा गया", "musd_claim": "mUSD क्लेम करें", "perps_deposit": "फंड जोड़ें", + "perps_withdraw": "विदड्रॉवल", "predict_deposit": "फंड जोड़ें", "swap": "टोकन स्वैप करें", "swap_approval": "टोकन एप्रूव करें" @@ -8082,6 +8158,7 @@ "sites": "साइट्स", "popular_sites": "पॉपुलर साइटें", "search_sites": "साइट ढूंढें", + "view_all": "सभी देखें", "enable_basic_functionality": "बेसिक फंक्शनलिटी को चालू करें", "basic_functionality_disabled_title": "एक्सप्लोर उपलब्ध नहीं है", "basic_functionality_disabled_description": "बेसिक फंक्शनलिटी बंद होने पर हम ज़रूरी मेटाडेटा नहीं ला सकते।", @@ -8199,6 +8276,7 @@ "perpetuals": "परपेचुअल्स", "predictions": "प्रेडिक्शंस", "whats_happening": "क्या हो रहा है", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "जियोपॉलिटिकल", "macro": "मैक्रो", diff --git a/locales/languages/id.json b/locales/languages/id.json index f6b193a5f85..92e368ff22b 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Akses dibatasi", + "description_line1": "Alamat dompet ini telah ditandai selama pemeriksaan kepatuhan. Akibatnya, beberapa layanan MetaMask tidak tersedia.", + "description_line2": "Jika Anda yakin ini merupakan kesalahan, hubungi dukungan untuk meminta peninjauan.", + "contact_support": "Hubungi dukungan" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Autentikasi biometrik dibatalkan", "biometric_authentication_cancelled_title": "Pengaturan Biometrik Gagal", "biometric_authentication_cancelled_description": "Atur ulang autentikasi biometrik dari pengaturan.", - "biometric_authentication_cancelled_button": "Konfirmasikan" + "biometric_authentication_cancelled_button": "Konfirmasikan", + "biometric_changed": "Biometrik Diubah", + "biometric_changed_alert_desc": "Biometrik Anda telah diubah. Aktifkan kembali biometrik dari pengaturan.", + "biometric_changed_alert_confirm": "Konfirmasikan" }, "connect_hardware": { "title_select_hardware": "Hubungkan dompet perangkat keras", @@ -1008,6 +1011,11 @@ "see_more": "Lihat selengkapnya" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Perp tidak tersedia", "title": "Perps", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Stop loss harus dilakukan pada harga {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Stop loss harus dilakukan pada harga likuidasi {{direction}}", "stop_loss_order_view_warning": "Stop loss merupakan harga likuidasi {{direction}}", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Take profit harus ditetapkan pada harga {{direction}} {{priceType}}. Perbarui atau hapus untuk membuat order.", + "stop_loss_wrong_side_warning": "Stop loss harus ditetapkan pada harga {{direction}} {{priceType}}. Perbarui atau hapus untuk membuat order.", "above": "di atas", "below": "di bawah", "done": "Selesai", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Estimasi {{time}} detik", "placing_prediction": "Membuat prediksi", "prediction_placed": "Prediksi dibuat", + "prediction_failed": "Failed to place prediction", "order_failed": "Pesanan gagal", "payments_made_in_usdc": "Semua pembayaran dilakukan dalam USDC", "prediction_insufficient_funds": "Dana tidak cukup. Anda dapat menggunakan maksimal {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Order gagal", "order_failed_body": "Likuiditas pada harga ini tidak cukup. Ingin mencoba lagi?", "try_again": "Coba lagi", + "view": "Lihat", "yes_buy": "Ya, beli", "yes_sell": "Ya, jual" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Terjadi kesalahan tidak dikenal", "order_not_fully_filled": "Gagal memenuhi order Anda", "buy_order_not_fully_filled": "Saham yang tersedia pada harga pasar tidak cukup untuk membuat order sekarang.", - "sell_order_not_fully_filled": "Permintaan pada harga pasar tidak cukup untuk mencairkannya saat ini." + "sell_order_not_fully_filled": "Permintaan pada harga pasar tidak cukup untuk mencairkannya saat ini.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Pilih pemenang", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Dana prediksi", "tx_review_predict_claim": "Klaim kemenangan", "tx_review_predict_withdraw": "Prediksi penarikan", + "tx_review_perps_withdraw": "Penarikan perp", "tx_review_musd_conversion": "Konversi mUSD", "claim": "Klaim", "sent_ether": "Mengirim ETH", @@ -5992,6 +6004,7 @@ "percentage_bonus": "Bonus {{percentage}}%", "claimable_bonus": "Bonus yang dapat diklaim", "claim_bonus": "Klaim bonus", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Bonus akan dibayarkan melalui {{networkName}}.", "percentage_bonus_on_linea": "Bonus {{percentage}}% di Linea", "claim": "Klaim", @@ -6152,53 +6165,53 @@ }, "money": { "title": "Money", - "apy_label": "{{percentage}}% APY", + "apy_label": "APY {{percentage}}%", "action": { - "add": "Add", + "add": "Tambah", "transfer": "Transfer", - "card": "Card" + "card": "Kartu" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Posisi Anda", + "current_rate": "Tarif saat ini", + "lifetime_earnings": "Penghasilan seumur hidup", + "available_balance": "Saldo tersedia" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Cara kerjanya", + "description": "Simpan mUSD di Akun Money dan dapatkan penghasilan secara otomatis. Nilainya didukung dolar, selalu likuid, dan siap untuk digunakan, diperdagangkan, atau dikirim setiap saat.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Tambah" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Potensi penghasilan", + "amount": "+$26.800", + "description": "Lihat bagaimana uang Anda dapat bertambah nilainya dari waktu ke waktu dengan mengonversikan kripto menjadi mUSD.", + "convert": "Konversikan", + "no_fee": "Tidak ada biaya", + "see_earnings": "Lihat potensi penghasilan" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "Kartu MetaMask", + "subtitle": "Gunakan uang Anda di mana saja.", + "virtual_card": "Kartu virtual", + "metal_card": "Kartu logam", + "cashback": "cashback {{percentage}}%", + "get_now": "Dapatkan sekarang" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Mengapa MetaMask Money?", + "benefit_auto_earn": "Penghasilan otomatis ", + "benefit_dollar_backed": "Uang Anda disimpan dalam mUSD, stablecoin yang didukung dolar dengan rasio 1:1", + "benefit_liquidity": "Likuiditas penuh tanpa penguncian, sehingga Anda dapat melakukan perdagangan atau penarikan setiap saat", + "benefit_spend_prefix": "Gunakan di lebih dari 150 juta merchant dengan Kartu MetaMask dan dapatkan ", + "benefit_spend_cashback": "cashback 1-3%", + "benefit_global": "Kirim dan terima uang secara global tanpa perantara", + "learn_more": "Pelajari selengkapnya" }, "footer": { - "add_money": "Add money" + "add_money": "Tambahkan uang" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Pembaruan akun", "approve": "Setujui permintaan", "perps_deposit": "Tambahkan dana", + "perps_withdraw": "Tarik", "predict_deposit": "Tambahkan dana Prediction", "predict_withdraw": "Tarik", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Situs ini meminta izin untuk menggunakan token Anda.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Transaksi {{index}}", "transaction": "Transaksi", "available_balance": "Saldo tersedia: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Saldo Perp yang tersedia: ", "edit_amount_done": "Lanjutkan", "deposit_edit_amount_done": "Tambahkan dana", "deposit_edit_amount_predict_withdraw": "Tarik", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Coba lagi", "no_internet_connection_title": "Tidak dapat terhubung", "no_internet_connection_description": "Koneksi internet Anda tidak stabil. Periksa koneksi dan coba lagi.", - "no_internet_connection_button": "Coba lagi" + "no_internet_connection_button": "Coba lagi", + "ios_need_update_title": "pembaruan iOS diperlukan", + "ios_need_update_description": "Google Sign-In MetaMask akan segera diperlukan ", + "ios_need_update_description_version": "iOS 17.4 atau lebih baru", + "ios_need_update_description_end": ". Anda dapat terus menggunakan Google Sign-In di perangkat ini untuk sementara waktu, tetapi fitur ini tidak akan lagi didukung pada pembaruan mendatang.", + "ios_need_update_description2": "Anda masih bisa mengakses dompet menggunakan akun Google yang sama pada perangkat yang didukung atau ekstensi MetaMask. Kami sangat menyarankan untuk mencadangkan Frasa Pemulihan Rahasia untuk memastikan akses tanpa gangguan.", + "ios_need_update_button": "Lanjutkan" }, "password_hint": { "title": "Petunjuk kata sandi", @@ -7430,7 +7450,9 @@ "loading": "Memuat token yang tersedia...", "load_error": "Tidak dapat memuat token. coba lagi.", "retry": "Coba lagi", - "on_linea": "di Linea" + "on_linea": "di Linea", + "account_label": "Akun", + "token_label": "Token" }, "cashback_screen": { "title": "Cashback", @@ -7881,6 +7903,7 @@ "daily_bonus": "Bonus yang dapat diklaim setiap hari", "annualized_bonus": "Bonus tahunan", "disclaimer": "Ini hanya estimasi. Bonus dapat berubah sewaktu-waktu.", + "disclaimer_brief": "Bonus tersebut merupakan estimasi dan dapat berubah.", "buy_button": "Beli mUSD", "swap_button": "Swap ke mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Mulai {{date}}", "ends_date": "Berakhir {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Selanjutnya", + "ended_date": "Berakhir pada {{date}}", + "pill_up_next": "Segera hadir", "pill_active": "Langsung", "pill_complete": "Selesaikan", "enter_now": "Masuk sekarang", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Ketentuan Penggunaan dan Pemberitahuan Privasi Tambahan", "opt_in_sheet_description_post_link": "Kami akan melacak aktivitas onchain untuk memberikan reward secara otomatis.", "geo_restriction_banner_title": "Tidak tersedia di wilayah Anda", - "geo_restriction_banner_description": "Kampanye ini tidak tersedia di wilayah Anda karena peraturan setempat." + "geo_restriction_banner_description": "Kampanye ini tidak tersedia di wilayah Anda karena peraturan setempat.", + "opt_in_success_toast": "Selesai!" }, "campaign_mechanics": { "title": "Mekanika" @@ -7945,10 +7969,60 @@ "opted_in": "Anda telah memilih untuk ikut serta dalam kampanye ini", "opt_in_error": "Gagal ikut serta. Coba lagi.", "join_campaign": "Gabung dalam kampanye ini", + "entries_closed_title": "Pendaftaran ditutup", + "entries_closed_description": "Anda melewatkan jendela pendaftaran", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Memeriksa status keikutsertaan", "swap": "Swap", "how_it_works": "Cara kerjanya" }, + "ondo_campaign_leaderboard_position": { + "title": "Posisi Anda", + "rank": "Peringkat Saya", + "tier": "Tingkatan Saya", + "rate_of_return": "Laba", + "total_deposited": "Total Deposit", + "current_value": "Nilai Saat Ini", + "not_found": "Belum masuk papan peringkat. Periksa kembali nanti.", + "updated_at": "Terakhir diperbarui: {{time}}", + "error_loading": "Gagal memuat posisi Anda", + "error_loading_description": "Terjadi kesalahan saat memuat posisi Anda di papan peringkat. Coba lagi.", + "retry": "Coba lagi" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Nilai total", + "portfolio_pnl": "P&L", + "portfolio_pnl_percent": "P&L (%)", + "summary_cost_basis": "Dasar biaya", + "summary_net_deposit": "Deposit bersih", + "position_current_value": "Nilai", + "position_unrealized_pnl": "P&L", + "updated_at": "Terakhir diperbarui: {{time}}", + "error_loading": "Gagal memuat posisi.", + "error_loading_description": "Terjadi kesalahan saat memuat posisi Anda. Coba lagi.", + "retry": "Coba lagi", + "loading": "Memuat posisi...", + "empty": "Posisi belum ditemukan.", + "empty_description": "Mulailah mendapatkan reward dengan membuka posisi pada aset dunia nyata yang di tokenisasi.", + "empty_cta": "Buka posisi", + "position_units": "{{units}} saham" + }, + "ondo_campaign_leaderboard": { + "title": "Papan Peringkat", + "your_position": "Posisi Anda", + "of_total": "dari {{total}} peserta", + "total_participants": "{{count}} peserta", + "updated_at": "Terakhir diperbarui: {{time}}", + "error_loading": "Gagal memuat papan peringkat", + "error_loading_description": "Terjadi kesalahan saat memuat papan peringkat. Coba lagi.", + "error_loading_position": "Gagal memuat posisi Anda", + "retry": "Coba lagi", + "no_data": "Data papan peringkat tidak tersedia", + "no_entries_in_tier": "Belum ada peserta di tingkatan ini", + "not_yet_computed": "Papan peringkat belum dihitung. Periksa kembali nanti." + }, "campaigns_preview": { "title": "Kampanye", "coming_soon": "Segera hadir", @@ -7983,6 +8057,7 @@ "musd_conversion": "Dikonversi ke mUSD", "musd_claim": "mUSD yang diklaim", "perps_deposit": "Akun perp yang didanai", + "perps_withdraw": "Penarikan", "predict_claim": "Klaim kemenangan", "predict_deposit": "Akun Predict yang didanai", "predict_withdraw": "Penarikan", @@ -8010,6 +8085,7 @@ "musd_convert_send": "Mengirim {{sourceSymbol}} dari {{sourceChain}}", "musd_claim": "Klaim mUSD", "perps_deposit": "Tambahkan dana", + "perps_withdraw": "Penarikan", "predict_deposit": "Tambahkan dana", "swap": "Tukar token", "swap_approval": "Setujui token" @@ -8082,6 +8158,7 @@ "sites": "Situs", "popular_sites": "Situs populer", "search_sites": "Cari situs", + "view_all": "Lihat semua", "enable_basic_functionality": "Aktifkan fungsi dasar", "basic_functionality_disabled_title": "Fitur Jelajahi tidak tersedia", "basic_functionality_disabled_description": "Kami tidak dapat mengakses metadata yang diperlukan saat fungsi dasar dinonaktifkan.", @@ -8199,6 +8276,7 @@ "perpetuals": "Abadi", "predictions": "Prediksi", "whats_happening": "Apa yang sedang terjadi", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolitik", "macro": "Makro", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 3ef8fcc43ed..08eb3c080f5 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "アクセスが制限されました", + "description_line1": "このウォレットアドレスは、コンプライアンス審査の過程でフラグが立てられました。その結果、MetaMaskの一部のサービスをご利用いただけません。", + "description_line2": "これが誤りであると思われる場合は、サポートに連絡して再調査を依頼してください。", + "contact_support": "サポートへのお問い合わせ" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "生体認証がキャンセルされました", "biometric_authentication_cancelled_title": "生体認証の設定に失敗しました", "biometric_authentication_cancelled_description": "設定で生体認証を設定しなおしてください。", - "biometric_authentication_cancelled_button": "確定" + "biometric_authentication_cancelled_button": "確定", + "biometric_changed": "生体情報が変更されました", + "biometric_changed_alert_desc": "生体情報が変更されました。設定から生体認証を再度有効にしてください。", + "biometric_changed_alert_confirm": "確定" }, "connect_hardware": { "title_select_hardware": "ハードウェアウォレットの接続", @@ -1008,6 +1011,11 @@ "see_more": "もっと見る" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "パーペチュアルは利用できません", "title": "パーペチュアル", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "ストップロスは{{priceType}}価格よりも{{direction}}に設定する必要があります", "stop_loss_beyond_liquidation_error": "ストップロスは清算価格よりも{{direction}}に設定する必要があります", "stop_loss_order_view_warning": "ストップロスが清算価格よりも{{direction}}に設定されています", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "利益確定は{{direction}}の{{priceType}}価格でなければなりません。価格を更新または消去してから注文してください。", + "stop_loss_wrong_side_warning": "ストップロスは{{direction}}の{{priceType}}価格でなければなりません。価格を更新または消去してから注文してください。", "above": "上", "below": "下", "done": "完了", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "約{{time}}秒", "placing_prediction": "予測を作成中", "prediction_placed": "予測が作成されました", + "prediction_failed": "Failed to place prediction", "order_failed": "注文に失敗しました", "payments_made_in_usdc": "すべての支払いはUSDCで行われます", "prediction_insufficient_funds": "残高が不足しています。最大{{amount}}まで使用できます。", @@ -2322,6 +2331,7 @@ "order_failed_title": "注文に失敗しました", "order_failed_body": "この価格で十分な流動性がありませんでした。もう一度試しますか?", "try_again": "もう一度お試しください", + "view": "表示", "yes_buy": "はい、購入します", "yes_sell": "はい、売却します" }, @@ -2376,7 +2386,8 @@ "unknown_error": "不明なエラーが発生しました", "order_not_fully_filled": "注文に失敗しました", "buy_order_not_fully_filled": "現在、市場価格で発注するのに十分な株がありません。", - "sell_order_not_fully_filled": "現在、市場価格で出金するのに十分な需要がありません。" + "sell_order_not_fully_filled": "現在、市場価格で出金するのに十分な需要がありません。", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "勝者を選択", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "入金済みの予測", "tx_review_predict_claim": "請求済みの報酬", "tx_review_predict_withdraw": "予測の出金", + "tx_review_perps_withdraw": "パーペチュアルの引き出し", "tx_review_musd_conversion": "mUSDへの変換", "claim": "請求", "sent_ether": "ETHを送金しました", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}%のボーナス", "claimable_bonus": "獲得できるボーナス", "claim_bonus": "ボーナスを請求する", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "ボーナスは{{networkName}}上で支払われます。", "percentage_bonus_on_linea": "Lineaでの{{percentage}}%ボーナス", "claim": "請求", @@ -6151,54 +6164,54 @@ "your_stablecoins": "保有中のステーブルコイン" }, "money": { - "title": "Money", - "apy_label": "{{percentage}}% APY", + "title": "マネー", + "apy_label": "年換算利率 (APY) {{percentage}}%", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "追加", + "transfer": "送金", + "card": "カード" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "保有ポジション", + "current_rate": "現在のレート", + "lifetime_earnings": "累積報酬額", + "available_balance": "利用可能残高" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "報酬獲得の仕組み", + "description": "マネーアカウントでmUSDを保有すると、自動的に報酬を獲得できます。ドルの裏付けがあり、流動性が確保され、いつでもお買い物、取引、送金に利用できます。", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "追加" }, "potential_earnings": { - "title": "Potential earnings", + "title": "獲得予定額", "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "description": "仮想通貨をmUSDに換えることで、時間の経過とともにお金がどのように増えるかをご覧ください。", + "convert": "変換", + "no_fee": "手数料なし", + "see_earnings": "獲得予定額を見る" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMaskカード", + "subtitle": "どこでもお金を使えます。", + "virtual_card": "バーチャルカード", + "metal_card": "メタルカード", + "cashback": "{{percentage}}%のキャッシュバック", + "get_now": "今すぐ入手" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "MetaMask Moneyをおすすめする理由", + "benefit_auto_earn": "自動で報酬を獲得", + "benefit_dollar_backed": "あなたのお金は、ドルと1対1で裏付けられたステーブルコイン、mUSDで保持されます。", + "benefit_liquidity": "ロックアップなしで完全な流動性が確保され、いつでも取引や引き出しが可能です", + "benefit_spend_prefix": "1億5000万店舗を超える加盟店でMetaMaskカードを利用して、", + "benefit_spend_cashback": "1~3%のキャッシュバックを獲得", + "benefit_global": "仲介者なしでグローバルに送金を実行&受取り", + "learn_more": "詳細" }, "footer": { - "add_money": "Add money" + "add_money": "お金を追加" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "アカウントのアップデート", "approve": "要求の承認", "perps_deposit": "資金を追加", + "perps_withdraw": "出金", "predict_deposit": "予測資金を追加", "predict_withdraw": "出金", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "このサイトがトークンの使用許可を求めています。", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "トランザクション {{index}}", "transaction": "トランザクション", "available_balance": "利用可能残高: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "使用可能なパーペチュアル残高: ", "edit_amount_done": "続行", "deposit_edit_amount_done": "資金を追加", "deposit_edit_amount_predict_withdraw": "出金", @@ -6810,7 +6824,13 @@ "oauth_error_button": "再試行してください", "no_internet_connection_title": "接続できません", "no_internet_connection_description": "インターネット接続が不安定です。接続を確認し、もう一度お試しください。", - "no_internet_connection_button": "再試行" + "no_internet_connection_button": "再試行", + "ios_need_update_title": "iOSのアップデートが必要です", + "ios_need_update_description": "MetaMask Googleサインインでは、まもなく", + "ios_need_update_description_version": "iOS 17.4以降", + "ios_need_update_description_end": "が必須要件となります。当面は、引き続きこのデバイスでGoogleサインインを利用できますが、次回のアップデートでサポートが終了します。", + "ios_need_update_description2": "引き続きウォレットにアクセスするには、対応デバイスで同じGoogleアカウントを使用するか、MetaMask拡張機能を利用してください。アクセスできなくなる事態を避けるため、シークレットリカバリーフレーズをバックアップすることを強くお勧めします。", + "ios_need_update_button": "続行" }, "password_hint": { "title": "パスワードのヒント", @@ -7430,7 +7450,9 @@ "loading": "ご利用可能なトークンを読み込み中...", "load_error": "トークンを読み込めません。もう一度お試しください。", "retry": "再試行してください", - "on_linea": "Lineaで" + "on_linea": "Lineaで", + "account_label": "アカウント", + "token_label": "トークン" }, "cashback_screen": { "title": "キャッシュバック", @@ -7881,6 +7903,7 @@ "daily_bonus": "1日あたりに獲得可能なボーナス", "annualized_bonus": "年率ボーナス", "disclaimer": "これはあくまで目安です。ボーナスは変更される場合があります。", + "disclaimer_brief": "ボーナスは見込み額であり、変動する場合があります。", "buy_button": "mUSDを購入", "swap_button": "mUSDにスワップ" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "{{date}}開始", "ends_date": "{{date}}終了", - "ended_date": "Ended {{date}}", - "pill_up_next": "次", + "ended_date": "{{date}}に終了", + "pill_up_next": "近日追加予定", "pill_active": "ライブ", "pill_complete": "完了", "enter_now": "今すぐ応募", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "補足利用規約およびプライバシー通知", "opt_in_sheet_description_post_link": "オンチェーンアクティビティを自動的に追跡し、リワードを付与します。", "geo_restriction_banner_title": "お客様の地域では利用できません", - "geo_restriction_banner_description": "現地の規制により、お住いの地域ではこのキャンペーンにご参加いただけません。" + "geo_restriction_banner_description": "現地の規制により、お住いの地域ではこのキャンペーンにご参加いただけません。", + "opt_in_success_toast": "完了!" }, "campaign_mechanics": { "title": "仕組み" @@ -7945,10 +7969,60 @@ "opted_in": "このキャンペーンにオプトインしました", "opt_in_error": "オプトインに失敗しました。もう一度お試しください。", "join_campaign": "キャンペーンに参加", + "entries_closed_title": "エントリーは終了しました", + "entries_closed_description": "オプトイン期間に間に合いませんでした", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "オプトインステータスを確認しています", "swap": "スワップ", "how_it_works": "報酬獲得の仕組み" }, + "ondo_campaign_leaderboard_position": { + "title": "あなたの順位", + "rank": "あなたのランク", + "tier": "あなたの階層", + "rate_of_return": "損益率", + "total_deposited": "合計デポジット額", + "current_value": "現在の価値", + "not_found": "まだリーダーボードに掲載されていません。後でもう一度確認してください。", + "updated_at": "最終更新時刻: {{time}}", + "error_loading": "順位の読み込みに失敗しました", + "error_loading_description": "リーダーボードの順位の読み込み中にエラーが発生しました。もう一度お試しください。", + "retry": "再試行" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "評価額の合計", + "portfolio_pnl": "損益額", + "portfolio_pnl_percent": "損益額 (%)", + "summary_cost_basis": "取得価格", + "summary_net_deposit": "純デポジット額", + "position_current_value": "価値", + "position_unrealized_pnl": "損益額", + "updated_at": "最終更新時刻: {{time}}", + "error_loading": "ポジションの読み込みに失敗しました。", + "error_loading_description": "ポジションの読み込み中にエラーが発生しました。もう一度お試しください。", + "retry": "再試行", + "loading": "ポジションを読み込み中...", + "empty": "まだポジションが見つかりません。", + "empty_description": "RWAトークンのポジションをオープンして、報酬の獲得を始めましょう。", + "empty_cta": "ポジションをオープン", + "position_units": "{{units}}株" + }, + "ondo_campaign_leaderboard": { + "title": "リーダーボード", + "your_position": "あなたの順位", + "of_total": "(参加総数{{total}}人)", + "total_participants": "参加総数{{count}}人", + "updated_at": "最終更新時刻: {{time}}", + "error_loading": "リーダーボードの読み込みに失敗しました", + "error_loading_description": "リーダーボードの読み込み中に問題が発生しました。もう一度お試しください。", + "error_loading_position": "順位の読み込みに失敗しました", + "retry": "再試行", + "no_data": "リーダーボードのデータが利用できません", + "no_entries_in_tier": "この階層にはまだ参加者がいません", + "not_yet_computed": "リーダーボードの計算が完了していません。少し時間を置いてから再確認してください。" + }, "campaigns_preview": { "title": "キャンペーン", "coming_soon": "近日追加予定", @@ -7983,6 +8057,7 @@ "musd_conversion": "mUSDへの変換完了", "musd_claim": "mUSDの請求完了", "perps_deposit": "パーペチュアルアカウントに入金しました", + "perps_withdraw": "出金", "predict_claim": "報酬を請求しました", "predict_deposit": "Predictアカウントに入金しました", "predict_withdraw": "出金", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceChain}}から{{sourceSymbol}}を送金", "musd_claim": "mUSDの請求", "perps_deposit": "資金を追加", + "perps_withdraw": "出金", "predict_deposit": "資金を追加", "swap": "トークンをスワップ", "swap_approval": "トークンを承認" @@ -8082,6 +8158,7 @@ "sites": "サイト", "popular_sites": "人気のサイト", "search_sites": "サイトを検索", + "view_all": "すべて表示", "enable_basic_functionality": "基本機能を有効にする", "basic_functionality_disabled_title": "閲覧を利用できません", "basic_functionality_disabled_description": "基本機能が無効になっていると、必要なメタデータを取得できません。", @@ -8199,6 +8276,7 @@ "perpetuals": "パーペチュアル", "predictions": "予測", "whats_happening": "現在起きていること", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "地政学", "macro": "マクロ", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 86259e2cd84..e74c1a1b2ea 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "액세스 제한", + "description_line1": "이 지갑 주소는 규정 준수 심사 중 플래그 처리되었습니다. 그 결과, 일부 MetaMask 서비스를 이용할 수 없습니다.", + "description_line2": "오류라고 생각되면 지원팀에 문의하여 검토를 요청하세요.", + "contact_support": "지원팀에 문의" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "바이오메트릭 인증 취소", "biometric_authentication_cancelled_title": "바이오메트릭 설정 실패", "biometric_authentication_cancelled_description": "설정에서 바이오메트릭 인증을 다시 설정해 주세요.", - "biometric_authentication_cancelled_button": "컨펌" + "biometric_authentication_cancelled_button": "컨펌", + "biometric_changed": "바이오메트릭 변경됨", + "biometric_changed_alert_desc": "바이오메트릭이 변경되었습니다. 설정에서 바이오메트릭을 다시 활성화하세요.", + "biometric_changed_alert_confirm": "컨펌" }, "connect_hardware": { "title_select_hardware": "하드웨어 지갑 연결", @@ -1008,6 +1011,11 @@ "see_more": "자세히 보기" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "무기한 선물을 사용할 수 없습니다", "title": "무기한 선물", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "손절은 {{direction}} {{priceType}} 가격이어야 합니다", "stop_loss_beyond_liquidation_error": "손절은 {{direction}} 청산 가격이어야 합니다", "stop_loss_order_view_warning": "손절이 {{direction}} 청산 가격입니다", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "익절 가격은 {{direction}} {{priceType}} 가격이어야 합니다. 주문하려면 이를 수정하거나 지우세요.", + "stop_loss_wrong_side_warning": "익절 가격은 {{direction}} {{priceType}} 가격이어야 합니다. 주문하려면 이를 수정하거나 지우세요.", "above": " 이상", "below": " 미만", "done": "완료", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "예상 소요 시간: {{time}}초", "placing_prediction": "예측 제출", "prediction_placed": "예측 제출함", + "prediction_failed": "Failed to place prediction", "order_failed": "주문 실패", "payments_made_in_usdc": "모든 결제는 USDC로 이루어집니다", "prediction_insufficient_funds": "자금이 부족합니다. 최대 {{amount}}까지 사용할 수 있습니다.", @@ -2322,6 +2331,7 @@ "order_failed_title": "주문 실패", "order_failed_body": "해당 가격에 체결 가능한 물량이 부족합니다. 다시 진행하시겠습니까?", "try_again": "다시 시도", + "view": "보기", "yes_buy": "예, 매수합니다", "yes_sell": "예, 매도합니다" }, @@ -2376,7 +2386,8 @@ "unknown_error": "알 수 없는 오류가 발생했습니다", "order_not_fully_filled": "주문을 완료하지 못했습니다", "buy_order_not_fully_filled": "시장가로 거래할 수 있는 주식이 충분하지 않아 주문을 넣을 수 없습니다.", - "sell_order_not_fully_filled": "현금화하기에 시장가 수요가 충분하지 않습니다." + "sell_order_not_fully_filled": "현금화하기에 시장가 수요가 충분하지 않습니다.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "승자 선택", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "예측 자금 충전 완료", "tx_review_predict_claim": "수익금 수령 완료", "tx_review_predict_withdraw": "예측 자금 출금", + "tx_review_perps_withdraw": "무기한 선물 출금", "tx_review_musd_conversion": "mUSD 전환", "claim": "청구", "sent_ether": "ETH 보냄", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}% 보너스", "claimable_bonus": "청구 가능한 보너스", "claim_bonus": "보너스 수령", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "보너스는 {{networkName}}에서 지급됩니다.", "percentage_bonus_on_linea": "Linea에서 {{percentage}}% 보너스", "claim": "청구", @@ -6154,51 +6167,51 @@ "title": "Money", "apy_label": "{{percentage}}% APY", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "추가", + "transfer": "송금", + "card": "카드" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "내 포지션", + "current_rate": "현재 비율", + "lifetime_earnings": "누적 수익", + "available_balance": "사용 가능한 잔액" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "작동 방식", + "description": "mUSD를 Money 계정에 보관하고 자동으로 수익을 받으세요. 달러에 연동되어 있고, 언제나 유동성이 보장되며, 언제든지 지출, 거래, 송금할 수 있습니다.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "추가" }, "potential_earnings": { - "title": "Potential earnings", + "title": "예상 수익", "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "description": "암호화폐를 mUSD로 전환했을 때 자금이 시간에 따라 어떻게 늘어날 수 있는지 확인해 보세요.", + "convert": "전환", + "no_fee": "요금 없음", + "see_earnings": "예상 수익 보기" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMask 카드", + "subtitle": "자금을 어디서나 사용하세요.", + "virtual_card": "가상 카드", + "metal_card": "메탈 카드", + "cashback": "{{percentage}}% 캐시백", + "get_now": "지금 받기" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "왜 MetaMask Money인가요?", + "benefit_auto_earn": "자동 수익 ", + "benefit_dollar_backed": "계정 내 자금은 1:1 달러 연동 스테이블코인인 mUSD로 보관됩니다", + "benefit_liquidity": "예치 기간 제한 없이 완전한 유동성을 제공하므로 언제든지 거래하거나 출금할 수 있습니다", + "benefit_spend_prefix": "MetaMask Card로 1억 5천만 개 이상의 가맹점에서 결제하고 ", + "benefit_spend_cashback": "1~3% 캐시백을 받으세요", + "benefit_global": "중개자 없이 전 세계로 송금하고 받을 수 있습니다", + "learn_more": "더 보기" }, "footer": { - "add_money": "Add money" + "add_money": "자금 추가" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "계정 업데이트", "approve": "요청 승인", "perps_deposit": "자금 추가", + "perps_withdraw": "출금", "predict_deposit": "예측 자금 추가", "predict_withdraw": "출금", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "이 사이트에서 토큰 사용 권한을 요청합니다.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "트랜잭션 {{index}}", "transaction": "트랜잭션", "available_balance": "사용 가능한 잔액: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "사용 가능한 잔액 잔액: ", "edit_amount_done": "계속", "deposit_edit_amount_done": "자금 추가", "deposit_edit_amount_predict_withdraw": "출금", @@ -6810,7 +6824,13 @@ "oauth_error_button": "다시 시도", "no_internet_connection_title": "연결할 수 없음", "no_internet_connection_description": "인터넷 연결이 불안정합니다. 연결을 확인하고 다시 시도하세요.", - "no_internet_connection_button": "다시 시도" + "no_internet_connection_button": "다시 시도", + "ios_need_update_title": "iOS 업데이트 필요", + "ios_need_update_description": "곧 MetaMask Google 로그인에는 ", + "ios_need_update_description_version": "iOS 17.4 이상의 버전이 필요합니다", + "ios_need_update_description_end": ". 현재는 이 기기에서 Google 로그인을 계속 사용할 수 있지만, 향후 업데이트에서는 더 이상 지원되지 않습니다.", + "ios_need_update_description2": "지원되는 기기 또는 MetaMask 확장 프로그램에서 동일한 Google 계정으로 계속 지갑에 액세스할 수 있습니다. 중단 없이 지갑에 액세스할 수 있도록 비밀복구구문을 반드시 백업해 두시기를 강력히 권장합니다.", + "ios_need_update_button": "계속" }, "password_hint": { "title": "비밀번호 힌트", @@ -7430,7 +7450,9 @@ "loading": "사용 가능한 토큰 불러오는 중...", "load_error": "토큰을 불러올 수 없습니다. 다시 시도해 주세요.", "retry": "다시 시도", - "on_linea": "Linea 네트워크에서" + "on_linea": "Linea 네트워크에서", + "account_label": "계정", + "token_label": "토큰" }, "cashback_screen": { "title": "캐시백", @@ -7881,6 +7903,7 @@ "daily_bonus": "매일 청구 가능 보너스", "annualized_bonus": "연환산 보너스", "disclaimer": "이는 추정치일 뿐입니다. 보너스는 변경될 수 있습니다.", + "disclaimer_brief": "보너스는 예상치이며 변경될 수 있습니다.", "buy_button": "mUSD 구매", "swap_button": "mUSD로 스왑" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "시작일: {{date}}", "ends_date": "종료일: {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "다음 일정", + "ended_date": "{{date}}에 종료됨", + "pill_up_next": "곧 추가 예정", "pill_active": "진행 중", "pill_complete": "완료", "enter_now": "지금 참가하기", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "추가 이용 약관 및 개인정보 처리방침에 동의하는 것이 됩니다", "opt_in_sheet_description_post_link": "MetaMask는 온체인 활동을 추적하여 보상을 자동으로 지급합니다.", "geo_restriction_banner_title": "회원님의 지역에서 사용할 수 없습니다", - "geo_restriction_banner_description": "현지 규정으로 인해 이 캠페인은 거주 지역에서 사용할 수 없습니다." + "geo_restriction_banner_description": "현지 규정으로 인해 이 캠페인은 거주 지역에서 사용할 수 없습니다.", + "opt_in_success_toast": "가입이 완료되었습니다!" }, "campaign_mechanics": { "title": "운영 방식" @@ -7945,10 +7969,60 @@ "opted_in": "이 캠페인에 참여했습니다", "opt_in_error": "참여하지 못했습니다. 다시 시도하세요.", "join_campaign": "캠페인 참여", + "entries_closed_title": "참여 접수 마감", + "entries_closed_description": "옵트인 기간을 놓쳤습니다", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "참여 상태 확인 중", "swap": "스와프", "how_it_works": "작동 방식" }, + "ondo_campaign_leaderboard_position": { + "title": "내 포지션", + "rank": "내 순위", + "tier": "내 티어", + "rate_of_return": "수익", + "total_deposited": "총 예치 금액", + "current_value": "현재 가치", + "not_found": "아직 리더보드에 표시되지 않았습니다. 나중에 다시 확인해 주세요.", + "updated_at": "마지막 업데이트: {{time}}", + "error_loading": "포지션 불러오기 실패", + "error_loading_description": "리더보드 포지션을 불러오는 중 오류가 발생했습니다. 다시 시도해 주세요.", + "retry": "다시 시도" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "총 가치", + "portfolio_pnl": "손익", + "portfolio_pnl_percent": "손익(%)", + "summary_cost_basis": "취득 원가", + "summary_net_deposit": "순예치액", + "position_current_value": "가격", + "position_unrealized_pnl": "손익", + "updated_at": "마지막 업데이트: {{time}}", + "error_loading": "포지션 불러오기 실패.", + "error_loading_description": "포지션을 불러오는 중 오류가 발생했습니다. 다시 시도해 주세요.", + "retry": "다시 시도", + "loading": "포지션 불러오는 중...", + "empty": "포지션을 찾을 수 없음.", + "empty_description": "토큰화된 실물 자산에서 포지션을 개설해 보상 적립을 시작하세요.", + "empty_cta": "포지션 개설", + "position_units": "{{units}}주" + }, + "ondo_campaign_leaderboard": { + "title": "리더보드", + "your_position": "내 포지션", + "of_total": "전체 {{total}}명 중", + "total_participants": "참여자 {{count}}명", + "updated_at": "마지막 업데이트: {{time}}", + "error_loading": "리더보드 불러오기 실패", + "error_loading_description": "리더보드를 불러오는 중 문제가 발생했습니다. 다시 시도해 주세요.", + "error_loading_position": "포지션 불러오기 실패", + "retry": "다시 시도", + "no_data": "사용 가능한 리더보드 데이터가 없습니다", + "no_entries_in_tier": "이 티어에는 아직 참여자가 없습니다", + "not_yet_computed": "리더보드가 아직 집계되지 않았습니다. 곧 다시 확인해 주세요." + }, "campaigns_preview": { "title": "캠페인", "coming_soon": "곧 추가 예정", @@ -7983,6 +8057,7 @@ "musd_conversion": "mUSD로 전환 완료", "musd_claim": "mUSD 수령 완료", "perps_deposit": "입금 완료된 무기한 선물 계정", + "perps_withdraw": "출금", "predict_claim": "수익금 수령함", "predict_deposit": "예측 계정에 입금함", "predict_withdraw": "출금", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceChain}}에서 {{sourceSymbol}} 전송됨", "musd_claim": "mUSD 받기", "perps_deposit": "자금 추가", + "perps_withdraw": "출금", "predict_deposit": "자금 추가", "swap": "토큰 스왑", "swap_approval": "토큰 승인" @@ -8082,6 +8158,7 @@ "sites": "사이트", "popular_sites": "인기 사이트", "search_sites": "사이트 검색", + "view_all": "모두 보기", "enable_basic_functionality": "기본 기능 활성화", "basic_functionality_disabled_title": "탐색 기능을 사용할 수 없습니다", "basic_functionality_disabled_description": "기본 기능이 비활성화되어 있어 필요한 메타데이터를 가져올 수 없습니다.", @@ -8199,6 +8276,7 @@ "perpetuals": "영구계약", "predictions": "예측", "whats_happening": "주요 동향", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "지정학", "macro": "거시 경제", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 8c7f9cc0956..c8ef59a1a4c 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Acesso restrito", + "description_line1": "Este endereço de carteira foi sinalizado durante a verificação de conformidade. Como resultado, alguns serviços da MetaMask estão indisponíveis.", + "description_line2": "Se você acredita que isso é um erro, entre em contato com o suporte para solicitar uma revisão.", + "contact_support": "Falar com o suporte" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Autenticação biométrica cancelada", "biometric_authentication_cancelled_title": "Configuração biométrica falhou", "biometric_authentication_cancelled_description": "Reconfigure a autenticação biométrica nas configurações.", - "biometric_authentication_cancelled_button": "Confirmar" + "biometric_authentication_cancelled_button": "Confirmar", + "biometric_changed": "Alteração da biometria", + "biometric_changed_alert_desc": "Seus dados biométricos foram alterados. Reative a biometria nas configurações.", + "biometric_changed_alert_confirm": "Confirmar" }, "connect_hardware": { "title_select_hardware": "Conectar uma carteira de hardware", @@ -1008,6 +1011,11 @@ "see_more": "Ver mais" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Perps (futuros perpétuos) não disponíveis", "title": "Perps", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "O stop loss deve ser um preço {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "O stop loss deve ser um preço de liquidação {{direction}}", "stop_loss_order_view_warning": "O stop loss é um preço de liquidação {{direction}}", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "O Take Profit deve estar {{direction}} do preço {{priceType}}. Atualize ou remova-o para enviar a ordem.", + "stop_loss_wrong_side_warning": "O stop loss deve estar {{direction}} do preço {{priceType}}. Atualize ou remova-o para enviar a ordem.", "above": "acima", "below": "abaixo", "done": "Pronto", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Estimado: {{time}} segundos", "placing_prediction": "Colocando uma previsão", "prediction_placed": "Previsão colocada", + "prediction_failed": "Failed to place prediction", "order_failed": "Falha na ordem", "payments_made_in_usdc": "Todos os pagamentos são feitos em USDC", "prediction_insufficient_funds": "Fundos insuficientes. Você pode usar até {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Ordem falhou", "order_failed_body": "Não havia liquidez suficiente a esse preço. Deseja tentar novamente?", "try_again": "Tentar novamente", + "view": "Ver", "yes_buy": "Sim, compre", "yes_sell": "Sim, venda" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Ocorreu um erro desconhecido", "order_not_fully_filled": "Não foi possível executar sua ordem", "buy_order_not_fully_filled": "Não há ações suficientes disponíveis ao preço de mercado para executar sua ordem neste momento.", - "sell_order_not_fully_filled": "Demanda ao preço de mercado insuficiente para resgatar neste momento." + "sell_order_not_fully_filled": "Demanda ao preço de mercado insuficiente para resgatar neste momento.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Escolha um ganhador", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Previsões creditadas", "tx_review_predict_claim": "Ganhos resgatados", "tx_review_predict_withdraw": "Previsões retiradas", + "tx_review_perps_withdraw": "Perps sacados", "tx_review_musd_conversion": "Conversão de mUSD", "claim": "Resgatar", "sent_ether": "ETH enviado", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}% de bônus", "claimable_bonus": "Bônus resgatável", "claim_bonus": "Resgatar bônus", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "O bônus será pago em {{networkName}}.", "percentage_bonus_on_linea": "Bônus de {{percentage}}% na Linea", "claim": "Resgatar", @@ -6151,54 +6164,54 @@ "your_stablecoins": "Suas stablecoins" }, "money": { - "title": "Money", + "title": "Dinheiro", "apy_label": "{{percentage}}% APY", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Adicionar", + "transfer": "Transferir", + "card": "Cartão" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Sua posição", + "current_rate": "Taxa atual", + "lifetime_earnings": "Ganhos vitalícios", + "available_balance": "Saldo disponível" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", - "musd_name": "MetaMask USD", + "title": "Como funciona", + "description": "Mantenha mUSD em uma Conta Monetária e ganhe automaticamente. mUSD é lastreado em dólar, sempre líquido e pronto para gastar, negociar ou enviar quando você quiser.", + "musd_name": "USD MetaMask", "musd_symbol": "mUSD", - "add": "Add" + "add": "Adicionar" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Ganhos potenciais", + "amount": "+US$ 26.800", + "description": "Veja como seu dinheiro pode render ao longo do tempo convertendo suas criptomoedas em mUSD.", + "convert": "Converter", + "no_fee": "Nenhuma taxa", + "see_earnings": "Veja ganhos potenciais" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "Cartão MetaMask", + "subtitle": "Gaste seu dinheiro onde desejar.", + "virtual_card": "Cartão virtual", + "metal_card": "Cartão Metal", + "cashback": "{{percentage}}% de cashback", + "get_now": "Adquira já" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Por que usar o MetaMask Money?", + "benefit_auto_earn": "Ganhe automaticamente ", + "benefit_dollar_backed": "Seu dinheiro é mantido em mUSD, uma stablecoin lastreada em dólar na proporção de 1:1", + "benefit_liquidity": "Liquidez total sem períodos de bloqueio, para que você possa negociar ou sacar quando quiser.", + "benefit_spend_prefix": "Gaste em mais de 150 milhões de estabelecimentos comerciais com o cartão MetaMask e ganhe", + "benefit_spend_cashback": "1-3% de cashback", + "benefit_global": "Envie e receba dinheiro globalmente sem intermediários", + "learn_more": "Saiba mais" }, "footer": { - "add_money": "Add money" + "add_money": "Adicionar dinheiro" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Atualização da conta", "approve": "Aprovar solicitação", "perps_deposit": "Adicionar fundos", + "perps_withdraw": "Sacar", "predict_deposit": "Adicionar fundos de previsão", "predict_withdraw": "Sacar", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Este site quer permissão para gastar seus tokens.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Transação {{index}}", "transaction": "Transações", "available_balance": "Saldo disponível: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Saldo de perps disponível: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Adicionar fundos", "deposit_edit_amount_predict_withdraw": "Sacar", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Tentar novamente", "no_internet_connection_title": "Não é possível conectar", "no_internet_connection_description": "Sua conexão de internet está instável. Verifique sua conexão e tente novamente.", - "no_internet_connection_button": "Tentar novamente" + "no_internet_connection_button": "Tentar novamente", + "ios_need_update_title": "É necessário atualizar o iOS", + "ios_need_update_description": "O login do Google no MetaMask em breve exigirá ", + "ios_need_update_description_version": "iOS 17.4 ou superior", + "ios_need_update_description_end": ". Você pode continuar usando o Google Sign-In neste dispositivo por enquanto, mas ele não será mais compatível em uma atualização futura.", + "ios_need_update_description2": "Você ainda pode acessar sua carteira usando a mesma conta do Google em um dispositivo compatível ou na extensão MetaMask. Recomendamos fortemente que você faça backup da sua Frase de Recuperação Secreta para garantir acesso ininterrupto.", + "ios_need_update_button": "Continuar" }, "password_hint": { "title": "Dica de senha", @@ -7430,7 +7450,9 @@ "loading": "Carregando tokens disponíveis...", "load_error": "Não foi possível carregar os tokens. Tente novamente.", "retry": "Tentar novamente", - "on_linea": "na Linea" + "on_linea": "na Linea", + "account_label": "Conta", + "token_label": "Token" }, "cashback_screen": { "title": "Cashback", @@ -7881,6 +7903,7 @@ "daily_bonus": "Bônus diário resgatável", "annualized_bonus": "Bônus anualizado", "disclaimer": "Este valor é apenas uma estimativa. O bônus está sujeito a alterações.", + "disclaimer_brief": "O bônus é uma estimativa e pode sofrer alterações.", "buy_button": "Comprar mUSD", "swap_button": "Converter para mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Começa em {{date}}", "ends_date": "Termina em {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Em seguida", + "ended_date": "Encerrado em {{date}}", + "pill_up_next": "Em breve", "pill_active": "Em tempo real", "pill_complete": "Concluído", "enter_now": "Insira agora", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Termos de uso suplementares e aviso de privacidade", "opt_in_sheet_description_post_link": "Rastrearemos a atividade onchain para recompensar você automaticamente.", "geo_restriction_banner_title": "Não disponível em sua região", - "geo_restriction_banner_description": "Esta campanha não está disponível na sua região devido a regulamentos locais." + "geo_restriction_banner_description": "Esta campanha não está disponível na sua região devido a regulamentos locais.", + "opt_in_success_toast": "Pronto!" }, "campaign_mechanics": { "title": "Mecânica" @@ -7945,10 +7969,60 @@ "opted_in": "Você optou por participar desta campanha", "opt_in_error": "Falha ao optar por participar. Tente novamente.", "join_campaign": "Participe da campanha", + "entries_closed_title": "Inscrições encerradas", + "entries_closed_description": "Você perdeu o prazo de adesão", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Verificando status de participação", "swap": "Troca", "how_it_works": "Como funciona" }, + "ondo_campaign_leaderboard_position": { + "title": "Sua posição", + "rank": "Meu rank", + "tier": "Meu nível", + "rate_of_return": "Retorno", + "total_deposited": "Total depositado", + "current_value": "Valor atual", + "not_found": "Ainda não está na tabela de classificação. Volte mais tarde.", + "updated_at": "Última atualização: {{time}}", + "error_loading": "Não foi possível carregar sua posição", + "error_loading_description": "Ocorreu um erro ao carregar sua posição na tabela de classificação. Tente novamente.", + "retry": "Tentar novamente" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Valor total", + "portfolio_pnl": "P&L", + "portfolio_pnl_percent": "P&L (%)", + "summary_cost_basis": "Base de custo", + "summary_net_deposit": "Depósito líquido", + "position_current_value": "Valor", + "position_unrealized_pnl": "P&L", + "updated_at": "Última atualização: {{time}}", + "error_loading": "Falha ao carregar posições.", + "error_loading_description": "Ocorreu um erro ao carregar suas posições. Tente novamente.", + "retry": "Tentar novamente", + "loading": "Carregando posições...", + "empty": "Nenhuma posição encontrada ainda.", + "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" + }, + "ondo_campaign_leaderboard": { + "title": "Tabela de classificação", + "your_position": "Sua posição", + "of_total": "de {{total}} participantes", + "total_participants": "{{count}} participantes", + "updated_at": "Última atualização: {{time}}", + "error_loading": "Falha ao carregar a tabela de classificação", + "error_loading_description": "Ocorreu um erro ao carregar a tabela de classificação. Tente novamente.", + "error_loading_position": "Não foi possível carregar sua posição", + "retry": "Tentar novamente", + "no_data": "Sem dados disponíveis na tabela de classificação", + "no_entries_in_tier": "Ainda não há participantes neste nível", + "not_yet_computed": "A tabela de classificação ainda não foi calculada. Volte mais tarde." + }, "campaigns_preview": { "title": "Campanhas", "coming_soon": "Em breve", @@ -7983,6 +8057,7 @@ "musd_conversion": "Convertido para mUSD", "musd_claim": "mUSD reivindicado", "perps_deposit": "Conta de perpétuos financiados", + "perps_withdraw": "Sacar", "predict_claim": "Ganhos resgatados", "predict_deposit": "Conta Predict creditada", "predict_withdraw": "Sacar", @@ -8010,6 +8085,7 @@ "musd_convert_send": "Enviado {{sourceSymbol}} de {{sourceChain}}", "musd_claim": "Reivindicar mUSD", "perps_deposit": "Adicionar fundos", + "perps_withdraw": "Sacar", "predict_deposit": "Adicionar fundos", "swap": "Trocar tokens", "swap_approval": "Aprovar tokens" @@ -8082,6 +8158,7 @@ "sites": "Sites", "popular_sites": "Websites populares", "search_sites": "Pesquisar websites", + "view_all": "Exibir tudo", "enable_basic_functionality": "Ativar funcionalidade básica", "basic_functionality_disabled_title": "A opção Explorar não está disponível", "basic_functionality_disabled_description": "Não é possível obter os metadados necessários quando a funcionalidade básica está desativada.", @@ -8199,6 +8276,7 @@ "perpetuals": "Perpétuos", "predictions": "Previsões", "whats_happening": "O que está acontecendo", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolítica", "macro": "Macro", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index cef95cbbccf..efb5ff75f3c 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Доступ ограничен", + "description_line1": "Этот адрес кошелька был отмечен во время проверки на соответствие требованиям. В результате некоторые сервисы MetaMask недоступны.", + "description_line2": "Если вы считаете, что это ошибка, обратитесь в службу поддержки для запроса проверки.", + "contact_support": "Связаться с поддержкой" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Биометрическая аутентификация отменена", "biometric_authentication_cancelled_title": "Биометрическая настройка не удалась", "biometric_authentication_cancelled_description": "Повторно настройте биометрическую аутентификацию в настройках.", - "biometric_authentication_cancelled_button": "Подтвердить" + "biometric_authentication_cancelled_button": "Подтвердить", + "biometric_changed": "Биометрия изменена", + "biometric_changed_alert_desc": "Ваши биометрические данные были изменены. Пожалуйста, снова включите биометрию в настройках.", + "biometric_changed_alert_confirm": "Подтвердить" }, "connect_hardware": { "title_select_hardware": "Подключить аппаратный кошелек", @@ -1008,6 +1011,11 @@ "see_more": "Подробнее" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Перпы недоступны", "title": "Перпы", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Стоп-лосс должен быть по цене {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Стоп-лосс должен быть равен цене ликвидации {{direction}}", "stop_loss_order_view_warning": "Стоп-лосс — это цена ликвидации {{direction}}", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Тейк-профит должен быть ценой {{direction}} {{priceType}}. Обновите или очистите его, чтобы разместить ордер.", + "stop_loss_wrong_side_warning": "Стоп-лосс должен быть ценой {{direction}} {{priceType}}. Обновите или удалите его, чтобы разместить ордер.", "above": "выше", "below": "ниже", "done": "Готово", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Примерно {{time}} секунд(-ы)", "placing_prediction": "Прогноз размещается", "prediction_placed": "Прогноз сделан", + "prediction_failed": "Failed to place prediction", "order_failed": "Ошибка ордера", "payments_made_in_usdc": "Все платежи осуществляются в USDC", "prediction_insufficient_funds": "Недостаточно средств. Вы можете использовать до {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Ордер не выполнен", "order_failed_body": "По этой цене было недостаточно ликвидности. Хотите повторить попытку?", "try_again": "Повторить попытку", + "view": "Просмотр", "yes_buy": "Да, купить", "yes_sell": "Да, продать" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Произошла неизвестная ошибка", "order_not_fully_filled": "Не удалось выполнить ваш ордер", "buy_order_not_fully_filled": "Недостаточно акций, доступных по рыночной цене, чтобы разместить ваш ордер прямо сейчас.", - "sell_order_not_fully_filled": "На данный момент нет достаточного спроса по рыночной цене, чтобы обналичить." + "sell_order_not_fully_filled": "На данный момент нет достаточного спроса по рыночной цене, чтобы обналичить.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Выберите победителя", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Прогнозы с финансированием", "tx_review_predict_claim": "Востребованные доходы", "tx_review_predict_withdraw": "Отмена прогнозов", + "tx_review_perps_withdraw": "Вывод из перпов", "tx_review_musd_conversion": "Конвертация mUSD", "claim": "Получить", "sent_ether": "Отправлены ETH", @@ -5992,6 +6004,7 @@ "percentage_bonus": "Бонус {{percentage}}%", "claimable_bonus": "Встребуемый бонус", "claim_bonus": "Получить бонус", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Бонус будет выплачен в сети {{networkName}}.", "percentage_bonus_on_linea": "Бонус {{percentage}}% на Linea", "claim": "Получить", @@ -6151,54 +6164,54 @@ "your_stablecoins": "Ваши стейблкоины" }, "money": { - "title": "Money", - "apy_label": "{{percentage}}% APY", + "title": "Деньги", + "apy_label": "{{percentage}}% годовых", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Добавить", + "transfer": "Перевести", + "card": "Карта" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Ваша позиция", + "current_rate": "Текущая ставка", + "lifetime_earnings": "Заработок за все время", + "available_balance": "Доступ. баланс" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Как это работает", + "description": "Храните mUSD на счете Money и зарабатывайте автоматически. Актив обеспечен долларом, всегда ликвиден и готов к тратам, торговле или отправке в любое время.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Добавить" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Потенциальный заработок", + "amount": "+26 800 $", + "description": "Посмотрите, как сумма ваших средств может вырасти со временем, если конвертировать вашу криптовалюту в mUSD.", + "convert": "Конвертировать", + "no_fee": "Без платы", + "see_earnings": "Посмотреть потенциальный заработок" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "Карта MetaMask", + "subtitle": "Тратьте свои деньги где угодно.", + "virtual_card": "Виртуальная карта", + "metal_card": "Металлическая карта", + "cashback": "{{percentage}}% кешбэка", + "get_now": "Получить сейчас" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Почему MetaMask Money?", + "benefit_auto_earn": "Автозаработок ", + "benefit_dollar_backed": "Ваши деньги хранятся в mUSD, стейблкоине, обеспеченном долларом 1:1", + "benefit_liquidity": "Полная ликвидность без блокировок, поэтому вы можете торговать или выводить средства в любое время", + "benefit_spend_prefix": "Тратьте средства у более чем 150 млн продавцов с картой MetaMask и зарабатывайте ", + "benefit_spend_cashback": "1-3% кэшбэка", + "benefit_global": "Отправляйте и получайте деньги по всему миру без посредников", + "learn_more": "Подробнее" }, "footer": { - "add_money": "Add money" + "add_money": "Пополнить" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Обновление счета", "approve": "Одобрить запрос", "perps_deposit": "Внести средства", + "perps_withdraw": "Вывести средства", "predict_deposit": "Внести средства для прогнозирования", "predict_withdraw": "Вывести средства", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Этот сайт запрашивает разрешение на трату ваших токенов.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Транзакция {{index}}", "transaction": "Защита", "available_balance": "Доступный баланс: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Доступный баланс перпов: ", "edit_amount_done": "Продолжить", "deposit_edit_amount_done": "Внести средства", "deposit_edit_amount_predict_withdraw": "Вывести средства", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Повторить попытку", "no_internet_connection_title": "Не удалось подключиться", "no_internet_connection_description": "Ваше подключение к интернету нестабильно. Проверьте подключение сети и повторите попытку.", - "no_internet_connection_button": "Повторите попытку" + "no_internet_connection_button": "Повторите попытку", + "ios_need_update_title": "Требуется обновление iOS", + "ios_need_update_description": "Для входа в MetaMask через Google скоро потребуется ", + "ios_need_update_description_version": "iOS 17.4 или более новая версия", + "ios_need_update_description_end": ". Пока вы можете продолжать использовать вход через Google на этом устройстве, но он больше не будет поддерживаться в следующем обновлении.", + "ios_need_update_description2": "Вы по-прежнему можете получить доступ к своему кошельку, используя тот же аккаунт Google, на поддерживаемом устройстве или через расширение MetaMask. Мы настоятельно рекомендуем сделать резервную копию вашей секретной фразы для восстановления, чтобы обеспечить бесперебойный доступ.", + "ios_need_update_button": "Продолжить" }, "password_hint": { "title": "Подсказка пароля", @@ -7430,7 +7450,9 @@ "loading": "Загрузка доступных токенов…", "load_error": "Не удалось загрузить токены. Попробуйте еще раз.", "retry": "Повторить попытку", - "on_linea": "на Linea" + "on_linea": "на Linea", + "account_label": "Счет", + "token_label": "Токен" }, "cashback_screen": { "title": "Кэшбэк", @@ -7881,6 +7903,7 @@ "daily_bonus": "Ежедневный бонус, доступный для получения", "annualized_bonus": "Годовой бонус", "disclaimer": "Это лишь приблизительная оценка. Размер бонуса может измениться.", + "disclaimer_brief": "Бонус является приблизительным и может измениться.", "buy_button": "Купить mUSD", "swap_button": "Обменять на mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Начинается {{date}}", "ends_date": "Заканчивается {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Далее", + "ended_date": "Завершено {{date}}", + "pill_up_next": "Скоро появятся", "pill_active": "Идет сейчас", "pill_complete": "Завершено", "enter_now": "Принять участие", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Дополнительные условия использования и уведомление о конфиденциальности", "opt_in_sheet_description_post_link": "Мы будем отслеживать активность в сети, чтобы автоматически начислять вам вознаграждения.", "geo_restriction_banner_title": "Недоступно в вашем регионе", - "geo_restriction_banner_description": "Данная кампания недоступна в вашем регионе в связи с местными правилами." + "geo_restriction_banner_description": "Данная кампания недоступна в вашем регионе в связи с местными правилами.", + "opt_in_success_toast": "Вы в деле!" }, "campaign_mechanics": { "title": "Механизм" @@ -7945,10 +7969,60 @@ "opted_in": "Вы согласились на участие в этой кампании", "opt_in_error": "Не удалось согласиться. Повторите попытку.", "join_campaign": "Присоединиться к кампании", + "entries_closed_title": "Прием заявок закрыт", + "entries_closed_description": "Вы пропустили окно регистрации", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Проверить статуса согласия на участие", "swap": "Обменять", "how_it_works": "Как это работает" }, + "ondo_campaign_leaderboard_position": { + "title": "Ваша позиция", + "rank": "Мой ранг", + "tier": "Мой уровень", + "rate_of_return": "Доход", + "total_deposited": "Всего внесено", + "current_value": "Текущая стоимость", + "not_found": "Пока нет в таблице лидеров. Зайдите позже.", + "updated_at": "Последнее обновление: {{time}}", + "error_loading": "Не удалось загрузить вашу позицию", + "error_loading_description": "При загрузке вашей позиции в таблице лидеров возникла ошибка. Повторите попытку.", + "retry": "Повтор" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Общая стоимость", + "portfolio_pnl": "П/У", + "portfolio_pnl_percent": "П/У (%)", + "summary_cost_basis": "Базовая стоимость", + "summary_net_deposit": "Чистый депозит", + "position_current_value": "Значение", + "position_unrealized_pnl": "П/У", + "updated_at": "Последнее обновление: {{time}}", + "error_loading": "Не удалось загрузить позиции.", + "error_loading_description": "При загрузке ваших позиций возникла ошибка. Повторите попытку.", + "retry": "Повтор", + "loading": "Загрузка позиций...", + "empty": "Позиции пока не найдены.", + "empty_description": "Начните зарабатывать вознаграждения, открыв позицию в токенизированных активах реального мира.", + "empty_cta": "Открыть позицию", + "position_units": "{{units}} акции(-ий)" + }, + "ondo_campaign_leaderboard": { + "title": "Таблица лидеров", + "your_position": "Ваша позиция", + "of_total": "из {{total}} участников", + "total_participants": "{{count}} участника(-ов)", + "updated_at": "Последнее обновление: {{time}}", + "error_loading": "Не удалось загрузить таблицу лидеров", + "error_loading_description": "При загрузке таблицы лидеров возникла ошибка. Пожалуйста, попробуйте еще раз.", + "error_loading_position": "Не удалось загрузить вашу позицию", + "retry": "Повтор", + "no_data": "Данные таблицы лидеров недоступны", + "no_entries_in_tier": "На этом уровне пока нет участников", + "not_yet_computed": "Таблица лидеров еще не сформирована. Загляните позже." + }, "campaigns_preview": { "title": "Кампании", "coming_soon": "Скоро появятся", @@ -7983,6 +8057,7 @@ "musd_conversion": "Конвертировано в mUSD", "musd_claim": "mUSD получены", "perps_deposit": "Счет перпов пополнен", + "perps_withdraw": "Вывод средств", "predict_claim": "Востребованные выигрыши", "predict_deposit": "Счет «Прогнозирование» пополнен", "predict_withdraw": "Вывод средств", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceSymbol}} отправлено от {{sourceChain}}", "musd_claim": "Получить mUSD", "perps_deposit": "Внести средства", + "perps_withdraw": "Вывод средств", "predict_deposit": "Внести средства", "swap": "Обменять токены", "swap_approval": "Одобрить токены" @@ -8082,6 +8158,7 @@ "sites": "Сайты", "popular_sites": "Популярные сайты", "search_sites": "Поиск по сайтам", + "view_all": "Смотреть все", "enable_basic_functionality": "Включить базовый функционал", "basic_functionality_disabled_title": "Обзор недоступен", "basic_functionality_disabled_description": "Мы не можем получить необходимые метаданные, когда базовый функционал отключен.", @@ -8199,6 +8276,7 @@ "perpetuals": "Бессрочные контракты", "predictions": "Прогнозы", "whats_happening": "Что происходит", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Геополитика", "macro": "Макро", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index b2b6acb0a64..561615476cd 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Pinaghihigpitan ng access", + "description_line1": "Na-flag ang address ng wallet na ito habang sinusuri sa pagsunod. Bilang resulta, hindi available ang ilang mga serbisyo ng MetaMask.", + "description_line2": "Kung naniniwala ka na isa itong error, kontakin ang suporta para humiling ng pagsusuri.", + "contact_support": "Kontakin ang suporta" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Kinansela ang pag-authenticate sa biometric", "biometric_authentication_cancelled_title": "Nabigo ang pag-setup sa biometric", "biometric_authentication_cancelled_description": "Paki-setup muli ang pag-authenticate sa biometric mula sa mga setting.", - "biometric_authentication_cancelled_button": "Kumpirmahin" + "biometric_authentication_cancelled_button": "Kumpirmahin", + "biometric_changed": "Napalitan ang Biometric", + "biometric_changed_alert_desc": "Napalitan na ang biometric mo. Paganahing muli ang biometric sa mga setting.", + "biometric_changed_alert_confirm": "Kumpirmahin" }, "connect_hardware": { "title_select_hardware": "Magkonekta ng wallet na hardware", @@ -1008,6 +1011,11 @@ "see_more": "Tingnan pa" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Hindi available ang mga perp", "title": "Perps", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Ang stop loss ay dapat na {{direction}} {{priceType}} ang presyo", "stop_loss_beyond_liquidation_error": "Ang stop loss ay dapat na {{direction}} ang presyo ng liquidation", "stop_loss_order_view_warning": "Ang stop loss ay {{direction}} ang liquidation price", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Ang take profit ay dapat na {{direction}} {{priceType}} ang presyo. I-update o i-clear ito para maglagay ng order.", + "stop_loss_wrong_side_warning": "Ang stop loss ay dapat na {{direction}} {{priceType}} ang presyo. I-update o i-clear ito para maglagay ng order.", "above": "mas mataas", "below": "mas mababa", "done": "Tapos na", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Tinatayang {{time}} segundo", "placing_prediction": "Naglalagay ng prediksyon", "prediction_placed": "Nailagay na prediksyon", + "prediction_failed": "Failed to place prediction", "order_failed": "Nabigo ang order", "payments_made_in_usdc": "Lahat ng bayad ay isinagawa sa USDC", "prediction_insufficient_funds": "Hindi sapat ang pondo. Puwede kang gumamit ng hanggang {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Nabigo ang order", "order_failed_body": "Walang sapat na liquidity sa presyong ito. Nais na subukan muli?", "try_again": "Subukang muli", + "view": "Tingnan", "yes_buy": "Oo, bumili", "yes_sell": "Oo, ibenta" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Nagkaroon ng hindi kilalang error", "order_not_fully_filled": "Hindi na-fill ang order mo", "buy_order_not_fully_filled": "Hindi sapat ang mga share na available sa market price para mailagay ang order mo sa ngayon.", - "sell_order_not_fully_filled": "Walang sapat na demand sa market price para mag-cash out sa ngayon." + "sell_order_not_fully_filled": "Walang sapat na demand sa market price para mag-cash out sa ngayon.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Pumili ng nanalo", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Mga pinondohang prediksyon", "tx_review_predict_claim": "Mga na-claim na panalo", "tx_review_predict_withdraw": "Pagbawi ng mga prediksyon", + "tx_review_perps_withdraw": "Mag-withdraw perps", "tx_review_musd_conversion": "Palitan ng mUSD", "claim": "I-claim", "sent_ether": "Naipadala ang ETH", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}% bonus", "claimable_bonus": "Naki-claim na bonus", "claim_bonus": "I-claim ang bonus", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Ibibigay ang bonus sa {{networkName}}.", "percentage_bonus_on_linea": "{{percentage}}% bonus sa Linea", "claim": "I-claim", @@ -6151,54 +6164,54 @@ "your_stablecoins": "Mga stablecoin mo" }, "money": { - "title": "Money", + "title": "Pera", "apy_label": "{{percentage}}% APY", "action": { - "add": "Add", - "transfer": "Transfer", + "add": "Idagdag", + "transfer": "Maglipat", "card": "Card" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Posisyon mo", + "current_rate": "Kasalukuyang rate", + "lifetime_earnings": "Panghabambuhay na kita", + "available_balance": "Avail. na balanse" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Paano ito gumagana", + "description": "Mag-hold ng mUSD sa Account ng Pera at awtomatikong kumita. Sinusuportahan ito ng dolyar, laging liquid, at handang gastusin, i-trade, o ipadala anumang oras.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Idagdag" }, "potential_earnings": { - "title": "Potential earnings", + "title": "Potensyal na kikitain", "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "description": "Tingnan kung paano maaaring lumago ang pera mo sa paglipas ng panahon sa pamamagitan ng pag-convert ng iyong crypto sa mUSD.", + "convert": "I-convert", + "no_fee": "Walang bayarin", + "see_earnings": "Tingnan ang potensyal na kikitan" }, "metamask_card": { "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", + "subtitle": "Gastusin ang pera mo kahit saan.", "virtual_card": "Virtual card", "metal_card": "Metal card", "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "get_now": "Kunin ngayon" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "title": "Bakit Pera ng MetaMask?", + "benefit_auto_earn": "Awtomatikong kumita", + "benefit_dollar_backed": "Naka-hold ang pera mo sa mUSD, isan 1:1 na stablecoin na sinusuportahan ng dolyar", + "benefit_liquidity": "Ganap na liquidity na walang mga lockup, kaya maaari kang mag-trade o mag-withdraw anumang oras", + "benefit_spend_prefix": "Gastusin sa 150M+ merchant gamit ang MetaMask Card at kumita ", "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "benefit_global": "Magpadala at tumanggap ng pera sa buong nang walang tagapamagitan", + "learn_more": "Matuto pa" }, "footer": { - "add_money": "Add money" + "add_money": "Magdagdag ng pera" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Pag-update ng account", "approve": "Aprubahan ang kahilingan", "perps_deposit": "Magdagdag ng pondo", + "perps_withdraw": "Mag-withdraw", "predict_deposit": "Magdagdag ng mga pondo para sa Prediksyon", "predict_withdraw": "Mag-withdraw", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Kailangan ng site na ito ng pahintulot para gastusin ang mga token mo.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Transaksyon {{index}}", "transaction": "Transaksyon", "available_balance": "Available na balanse: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Available na balanse sa Perps: ", "edit_amount_done": "Magpatuloy", "deposit_edit_amount_done": "Magdagdag ng pondo", "deposit_edit_amount_predict_withdraw": "Mag-withdraw", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Subukang muli", "no_internet_connection_title": "Hindi makakonekta", "no_internet_connection_description": "Hindi maganda ang iyong koneksyon sa internet. Suriin ang iyong koneksyon at subukang muli.", - "no_internet_connection_button": "Subukang muli" + "no_internet_connection_button": "Subukang muli", + "ios_need_update_title": "kinakailangan ng update sa iOS", + "ios_need_update_description": "Kakailanganin na sa susunod ang MetaMask Google Sign-In ", + "ios_need_update_description_version": "iOS 17.4 o mas luma", + "ios_need_update_description_end": ". Maaari mong ipagpatuloy ang paggamit ng Google Sign-In sa device na ito sa ngayon, ngunit hindi na suportado sa paparating na update.", + "ios_need_update_description2": "Maaari mo pa ring mag-access ang wallet mo gamit ang parehong Google account sa suportadong device o MetaMask extension. Lubos naming inirerekomenda na i-back up ang iyong Secret Recovery Phrase upang matiyak ang walang patid na access.", + "ios_need_update_button": "Magpatuloy" }, "password_hint": { "title": "Hint sa password", @@ -7430,7 +7450,9 @@ "loading": "Naglo-load ng mga available na token...", "load_error": "Hindi makapag-load ng mga token. Pakisubukan muli.", "retry": "Subukang muli", - "on_linea": "sa Linea" + "on_linea": "sa Linea", + "account_label": "Account", + "token_label": "Token" }, "cashback_screen": { "title": "Cashback", @@ -7881,6 +7903,7 @@ "daily_bonus": "Maki-claim na bonus araw-araw", "annualized_bonus": "Taunang bonus", "disclaimer": "Pagtataya lamang ito. Maaaring magbago ang bonus.", + "disclaimer_brief": "Ang bonus ay tinantya lamang at maaaring magbago.", "buy_button": "Bumili ng mUSD", "swap_button": "I-swap sa mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Magsisimula sa {{date}}", "ends_date": "Matatapos sa {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Susunod", + "ended_date": "Natapos {{date}}", + "pill_up_next": "Paparating na", "pill_active": "Live", "pill_complete": "Kumpleto na", "enter_now": "Ilagay ngayon", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Karagdagang Mga Tuntunin ng Paggamit at Abiso sa Privacy", "opt_in_sheet_description_post_link": "Susubaybayan namin ang aktibidad sa onchain para agad kang mabigyan ng reward.", "geo_restriction_banner_title": "Hindi available sa rehiyon mo", - "geo_restriction_banner_description": "Hindi available ang campaign na ito sa iyong rehiyon dahil sa mga lokal na regulasyon." + "geo_restriction_banner_description": "Hindi available ang campaign na ito sa iyong rehiyon dahil sa mga lokal na regulasyon.", + "opt_in_success_toast": "Pasok ka na!" }, "campaign_mechanics": { "title": "Mechanics" @@ -7945,10 +7969,60 @@ "opted_in": "Nag-opt in ka sa campaign na ito", "opt_in_error": "Pumalaya ang pag-opt in. Pakisubukan muli.", "join_campaign": "Sumali sa campaign", + "entries_closed_title": "Sarado na ang mga entry", + "entries_closed_description": "Nalampasan mo ang opt-in window", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Sinusuri ang katayuan ng pag-opt in", "swap": "Mag-swap", "how_it_works": "Paano ito gumagana" }, + "ondo_campaign_leaderboard_position": { + "title": "Posisyon Mo", + "rank": "Aking Ranggo", + "tier": "Aking Tier", + "rate_of_return": "Tutubuin", + "total_deposited": "Kabuuang Idineposito", + "current_value": "Kasalukuyang Value", + "not_found": "Wala pa sa leaderboard. Tingnan muli mamaya.", + "updated_at": "Huling na-update: {{time}}", + "error_loading": "Hindi nai-load ang posisyon mo", + "error_loading_description": "Nagkaroon ng error sa pag-load ng posisyon mo sa leaderboard. Pakisubukan muli.", + "retry": "Subukang muli" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Kabuuang value", + "portfolio_pnl": "P&L", + "portfolio_pnl_percent": "P&L (%)", + "summary_cost_basis": "Batayan ng gastos", + "summary_net_deposit": "Net na deposito", + "position_current_value": "Value", + "position_unrealized_pnl": "P&L", + "updated_at": "Huling na-update: {{time}}", + "error_loading": "Hindi nai-load ang mga posisyon.", + "error_loading_description": "Nagkaroon ng error sa pag-load ng mga posisyon mo. Pakisubukan muli.", + "retry": "Subukang muli", + "loading": "Inilo-load ang mga posisyon...", + "empty": "Wala pang nakikitang mga 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" + }, + "ondo_campaign_leaderboard": { + "title": "Leaderboard", + "your_position": "Posisyon mo", + "of_total": "ng {{total}} (na) kalahok", + "total_participants": "{{count}} (na) kalahok", + "updated_at": "Huling na-update: {{time}}", + "error_loading": "Hindi nai-load ang leaderboard", + "error_loading_description": "Nagkaproblema habang nilo-load ang leaderboard. Pakisubukan muli.", + "error_loading_position": "Hindi nai-load ang posisyon mo", + "retry": "Subukang muli", + "no_data": "Walang available na data ng leaderboard", + "no_entries_in_tier": "Wala pang mga kalahok sa tier na ito", + "not_yet_computed": "Hindi pa na-compute ang leaderboard. Bumalik nalang agad." + }, "campaigns_preview": { "title": "Mga campaign", "coming_soon": "Paparating na", @@ -7983,6 +8057,7 @@ "musd_conversion": "Na-convert sa mUSD", "musd_claim": "Na-claim na mUSD", "perps_deposit": "Pinondohang perps account", + "perps_withdraw": "Pag-withdraw", "predict_claim": "Na-claim na mga panalo", "predict_deposit": "Pinondohang Hinulaang account", "predict_withdraw": "Pag-withdraw", @@ -8010,6 +8085,7 @@ "musd_convert_send": "Naipadala ang {{sourceSymbol}} mula sa {{sourceChain}}", "musd_claim": "I-claim ang mUSD", "perps_deposit": "Magdagdag ng pondo", + "perps_withdraw": "Pag-withdraw", "predict_deposit": "Magdagdag ng pondo", "swap": "Ipagpalit ang mga token", "swap_approval": "Aprubahan ang mga token" @@ -8082,6 +8158,7 @@ "sites": "Mga Site", "popular_sites": "Mga sikat na site", "search_sites": "Maghanap ng mga site", + "view_all": "Tingnan lahat", "enable_basic_functionality": "Paganahin ang basic functionality", "basic_functionality_disabled_title": "Hindi available ang pagtuklas", "basic_functionality_disabled_description": "Hindi namin makukuha ang kinakailangang metadata kapag hindi pinapagana ang basic functionality.", @@ -8199,6 +8276,7 @@ "perpetuals": "Perpetuals", "predictions": "Mga hula", "whats_happening": "Ano ang nangyayari", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Heopolitikal", "macro": "Makro", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 513ec976299..4db4d6f6fe5 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Erişim kısıtlandı", + "description_line1": "Bu cüzdan adresi uygunluk taraması sırasında işaretlendi. Sonuç olarak bazı MetaMask hizmetleri kullanılamıyor.", + "description_line2": "Bunun bir hata olduğuna inanıyorsanız inceleme talep etmek için destek bölümü ile iletişime geçin.", + "contact_support": "Destek ile iletişime geç" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Biyometrik doğrulama iptal edildi", "biometric_authentication_cancelled_title": "Biyometrik Kurulum Başarısız Oldu", "biometric_authentication_cancelled_description": "Lütfen ayarlar bölümünden biyometrik doğrulama kurulumunu tekrar yapın.", - "biometric_authentication_cancelled_button": "Onayla" + "biometric_authentication_cancelled_button": "Onayla", + "biometric_changed": "Biyometri Değiştirildi", + "biometric_changed_alert_desc": "Biyometriniz değiştirildi. Lütfen ayarlar bölümünden biyometriyi tekrar etkinleştirin.", + "biometric_changed_alert_confirm": "Onayla" }, "connect_hardware": { "title_select_hardware": "Bir donanım cüzdanı bağlayın", @@ -1008,6 +1011,11 @@ "see_more": "Daha fazlasını gör" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Sürekli vadeli işlem sözleşmeleri kullanılamıyor", "title": "Perps", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Zararda durdur emri {{direction}} yönünde {{priceType}} fiyatında olmalıdır", "stop_loss_beyond_liquidation_error": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında olmalıdır", "stop_loss_order_view_warning": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Kazancı al fiyatı {{direction}} {{priceType}} olmalıdır. Emir vermek için güncelleyin veya temizleyin.", + "stop_loss_wrong_side_warning": "Zararı durdur fiyatı {{direction}} {{priceType}} olmalıdır. Emir vermek için güncelleyin veya temizleyin.", "above": "üzerinde", "below": "altında", "done": "Bitti", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Tahmini {{time}} saniye", "placing_prediction": "Tahmin yapılıyor", "prediction_placed": "Tahmin yapıldı", + "prediction_failed": "Failed to place prediction", "order_failed": "Emir başarısız oldu", "payments_made_in_usdc": "Tüm ödemeler USDC cinsinden yapılır", "prediction_insufficient_funds": "Yeterli fon yok. En fazla {{amount}} kullanabilirsiniz.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Emir başarısız oldu", "order_failed_body": "Bu fiyatta yeterli likidite yoktu. Tekrar denemek ister misiniz?", "try_again": "Tekrar dene", + "view": "Görüntüle", "yes_buy": "Evet, al", "yes_sell": "Evet, sat" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Bilinmeyen bir hata oluştu", "order_not_fully_filled": "Emriniz gerçekleştirilemedi", "buy_order_not_fully_filled": "Şu anda emrinizi vermek için piyasa fiyatında yeterli hisse yok.", - "sell_order_not_fully_filled": "Şu anda piyasa fiyatında nakde çevirmek için yeterli talep yok." + "sell_order_not_fully_filled": "Şu anda piyasa fiyatında nakde çevirmek için yeterli talep yok.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Bir kazanan seç", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Fonlanmış tahminler", "tx_review_predict_claim": "Alınan kazançlar", "tx_review_predict_withdraw": "Tahmin bakiyesini çek", + "tx_review_perps_withdraw": "Perps çek", "tx_review_musd_conversion": "mUSD dönüştürme", "claim": "Al", "sent_ether": "ETH gönder", @@ -5992,6 +6004,7 @@ "percentage_bonus": "%{{percentage}} bonus", "claimable_bonus": "Alınabilir bonus", "claim_bonus": "Bonusu al", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Bonus, {{networkName}} üzerinde ödenecektir.", "percentage_bonus_on_linea": "Linea üzerinde %{{percentage}} bonus", "claim": "Al", @@ -6151,54 +6164,54 @@ "your_stablecoins": "Stabil kripto paralarınız" }, "money": { - "title": "Money", - "apy_label": "{{percentage}}% APY", + "title": "Para", + "apy_label": "%{{percentage}} Yıllık Bileşik Getiri", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Ekle", + "transfer": "Transfer Et", + "card": "Kart" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Pozisyonunuz", + "current_rate": "Güncel kur", + "lifetime_earnings": "Toplam kazançlar", + "available_balance": "Kull. bakiye" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Nasıl çalışır?", + "description": "Bir Para Hesabında mUSD tutun ve otomatik olarak kazanın. Dolar desteklidir, her zaman likittir; dilediğiniz zaman harcamaya, işleme ve gönderime hazırdır.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Ekle" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Potansiyel kazançlar", + "amount": "+26.800$", + "description": "Kriptonuzu mUSD'ye dönüştürerek paranızın zaman içinde nasıl artabileceğini görün.", + "convert": "Dönüştür", + "no_fee": "Ücret yok", + "see_earnings": "Potansiyel kazançları görün" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMask Kartı", + "subtitle": "Paranızı dilediğiniz yerde harcayın.", + "virtual_card": "Sanal kart", + "metal_card": "Metal kart", + "cashback": "%{{percentage}} para iadesi", + "get_now": "Hemen alın" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Neden MetaMask Para?", + "benefit_auto_earn": "Otomatik kazanın ", + "benefit_dollar_backed": "Paranız 1:1 dolar destekli bir stabil kripto para olan mUSD'de tutulur", + "benefit_liquidity": "Kilitleme olmadan tam likidite sayesinde dilediğiniz zaman işlem yapabilir veya para çekebilirsiniz", + "benefit_spend_prefix": "MetaMask Card ile 150 milyondana fazla satıcıda harcama yapın ve şunu kazanın ", + "benefit_spend_cashback": "%1-3 para iadesi", + "benefit_global": "Aracı olmadan dünya çapında para gönderin ve alın", + "learn_more": "Daha fazla bilgi edin" }, "footer": { - "add_money": "Add money" + "add_money": "Para ekle" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Hesap güncellemesi", "approve": "Talebi onayla", "perps_deposit": "Fon ekle", + "perps_withdraw": "Çek", "predict_deposit": "Tahmin fonu ekle", "predict_withdraw": "Çek", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Bu site token'larınızı harcamak için izin istiyor.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "{{index}} işlemi", "transaction": "İşlem", "available_balance": "Kullanılabilir bakiye: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Kullanılabilir Perps bakiyesi: ", "edit_amount_done": "Devam et", "deposit_edit_amount_done": "Fon ekle", "deposit_edit_amount_predict_withdraw": "Çek", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Tekrar dene", "no_internet_connection_title": "Bağlanılamıyor", "no_internet_connection_description": "İnternet bağlantınız kararlı değil. Bağlantınızı kontrol edip tekrar deneyin.", - "no_internet_connection_button": "Tekrar dene" + "no_internet_connection_button": "Tekrar dene", + "ios_need_update_title": "iOS güncellemesi gerekli", + "ios_need_update_description": "MetaMask Google ile Giriş için yakında şu gerekli olacak: ", + "ios_need_update_description_version": "iOS 17.4 veya üzeri", + "ios_need_update_description_end": ". Şimdilik bu cihazda Google ile Giriş özelliğini kullanmaya devam edebilirsiniz ancak yaklaşan bir güncellemede artık desteklenmez olacak.", + "ios_need_update_description2": "Desteklenen bir cihazda veya MetaMask uzantısında aynı Google hesabını kullanarak cüzdanınıza erişim sağlamaya devam edebilirsiniz. Kesintisiz erişim sağlamak için Gizli Kurtarma İfadenizi yedeklemenizi şiddetle tavsiye ederiz.", + "ios_need_update_button": "Devam et" }, "password_hint": { "title": "Şifre ipucu", @@ -7430,7 +7450,9 @@ "loading": "Kullanılabilir tokenler yükleniyor...", "load_error": "Tokenler yüklenemedi. Lütfen tekrar deneyin.", "retry": "Tekrar dene", - "on_linea": "Linea üzerinde" + "on_linea": "Linea üzerinde", + "account_label": "Hesap", + "token_label": "tokenlar" }, "cashback_screen": { "title": "Para iadesi", @@ -7881,6 +7903,7 @@ "daily_bonus": "Günlük alınabilir bonus", "annualized_bonus": "Yıllıklandırılmış bonus", "disclaimer": "Bu yalnızca bir tahmindir. Bonus değişikliğe tabidir.", + "disclaimer_brief": "Bonus tahminidir ve değişebilir.", "buy_button": "mUSD al", "swap_button": "mUSD'ye takas et" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Başlangıç tarihi {{date}}", "ends_date": "Bitiş tarihi {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Sırada", + "ended_date": "Bitiş tarihi {{date}}", + "pill_up_next": "Çok yakında", "pill_active": "Canlı", "pill_complete": "Tamamlandı", "enter_now": "Şimdi giriş yapın", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Ek Kullanım Şartları ve Gizlilik Bildirimi", "opt_in_sheet_description_post_link": "Sizi otomatik olarak ödüllendirmek için zincir üzeri etkinliğinizi takip edeceğiz.", "geo_restriction_banner_title": "Bölgenizde kullanılamıyor", - "geo_restriction_banner_description": "Bu kampanya yerel düzenlemelerden dolayı bölgenizde kullanılamıyor." + "geo_restriction_banner_description": "Bu kampanya yerel düzenlemelerden dolayı bölgenizde kullanılamıyor.", + "opt_in_success_toast": "Hazırsınız!" }, "campaign_mechanics": { "title": "İşleyiş" @@ -7945,10 +7969,60 @@ "opted_in": "Bu kampanyaya katıldınız", "opt_in_error": "Katılım sağlanamadı. Lütfen tekrar deneyin.", "join_campaign": "Kampanyaya katıl", + "entries_closed_title": "Girişler kapatıldı", + "entries_closed_description": "Katılım penceresini kaçırdınız", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Katılım durumu kontrol ediliyor", "swap": "Takas", "how_it_works": "Nasıl çalışır?" }, + "ondo_campaign_leaderboard_position": { + "title": "Pozisyonunuz", + "rank": "Sıralamam", + "tier": "Aşamam", + "rate_of_return": "Getiri", + "total_deposited": "Toplam Yatırılan", + "current_value": "Güncel Değer", + "not_found": "Henüz liderlik tablosunda değilsiniz. Lütfen daha sonra tekrar kontrol edin.", + "updated_at": "Son güncelleme: {{time}}", + "error_loading": "Pozisyonunuz yüklenemedi", + "error_loading_description": "Liderlik tablosundaki pozisyonunuz yüklenirken bir hata oldu. Lütfen tekrar deneyin.", + "retry": "Tekrar Dene" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Toplam değer", + "portfolio_pnl": "K&Z", + "portfolio_pnl_percent": "K&Z (%)", + "summary_cost_basis": "Maliyet temeli", + "summary_net_deposit": "Net yatırılan tutar", + "position_current_value": "Değer", + "position_unrealized_pnl": "K&Z", + "updated_at": "Son güncelleme: {{time}}", + "error_loading": "Pozisyonlar yüklenemedi.", + "error_loading_description": "Pozisyonlarınız yüklenirken bir hata oldu. Lütfen tekrar deneyin.", + "retry": "Tekrar Dene", + "loading": "Pozisyonlar yükleniyor...", + "empty": "Henüz pozisyon bulunamadı.", + "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" + }, + "ondo_campaign_leaderboard": { + "title": "Liderlik Tablosu", + "your_position": "Pozisyonunuz", + "of_total": "/ {{total}} katılımcı", + "total_participants": "{{count}} katılımcı", + "updated_at": "Son güncelleme: {{time}}", + "error_loading": "Liderlik tablosu yüklenemedi", + "error_loading_description": "Liderlik tablosu yüklenirken bir şeyler ters gitti. Lütfen tekrar deneyin.", + "error_loading_position": "Pozisyonunuz yüklenemedi", + "retry": "Tekrar Dene", + "no_data": "Liderlik tablosu verisi mevcut değil", + "no_entries_in_tier": "Bu aşamada henüz katılımcı yok", + "not_yet_computed": "Liderlik tablosu henüz hesaplanmadı. Kısa bir süre sonra tekrar kontrol edin." + }, "campaigns_preview": { "title": "Kampanyalar", "coming_soon": "Çok yakında", @@ -7983,6 +8057,7 @@ "musd_conversion": "mUSD'ye dönüştürüldü", "musd_claim": "mUSD alındı", "perps_deposit": "Fonlanmış sürekli vadeli işlem sözleşmeleri hesabı", + "perps_withdraw": "Çekme işlemi", "predict_claim": "Kazançlar alındı", "predict_deposit": "Fonlanmış Predict hesabı", "predict_withdraw": "Çekme işlemi", @@ -8010,6 +8085,7 @@ "musd_convert_send": "{{sourceChain}} alanından {{sourceSymbol}} gönderildi", "musd_claim": "mUSD al", "perps_deposit": "Fon ekle", + "perps_withdraw": "Çekme işlemi", "predict_deposit": "Fon ekle", "swap": "Token swap işlemi yapın", "swap_approval": "Token'leri onayla" @@ -8082,6 +8158,7 @@ "sites": "Siteler", "popular_sites": "Popüler siteler", "search_sites": "Siteleri ara", + "view_all": "Tümünü görüntüle", "enable_basic_functionality": "Temel işlevselliği etkinleştir", "basic_functionality_disabled_title": "Keşfet özelliği kullanılamıyor", "basic_functionality_disabled_description": "Temel işlevsellik devre dışıyken gerekli meta verileri getiremiyoruz.", @@ -8199,6 +8276,7 @@ "perpetuals": "Sürekli Vadeli İşlemler", "predictions": "Tahminler", "whats_happening": "Neler oluyor", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Jeopolitik", "macro": "Makro", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 01e8c54d2ac..4ad58d7c46c 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "Truy cập bị hạn chế", + "description_line1": "Địa chỉ ví này đã bị gắn cờ trong quá trình kiểm tra tuân thủ. Do đó, một số dịch vụ của MetaMask hiện không khả dụng.", + "description_line2": "Nếu bạn cho rằng đây là lỗi, hãy liên hệ bộ phận hỗ trợ để yêu cầu xem xét.", + "contact_support": "Liên hệ bộ phận hỗ trợ" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "Xác thực sinh trắc học đã bị hủy", "biometric_authentication_cancelled_title": "Thiết lập sinh trắc học thất bại", "biometric_authentication_cancelled_description": "Vui lòng thiết lập lại xác thực sinh trắc học trong phần cài đặt.", - "biometric_authentication_cancelled_button": "Xác nhận" + "biometric_authentication_cancelled_button": "Xác nhận", + "biometric_changed": "Sinh trắc học đã thay đổi", + "biometric_changed_alert_desc": "Sinh trắc học của bạn đã được thay đổi. Vui lòng bật lại sinh trắc học trong phần cài đặt.", + "biometric_changed_alert_confirm": "Xác nhận" }, "connect_hardware": { "title_select_hardware": "Kết nối ví cứng", @@ -1008,6 +1011,11 @@ "see_more": "Xem thêm" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Hợp đồng vĩnh cửu không khả dụng", "title": "Vĩnh cửu", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "Giá cắt lỗ phải {{direction}} giá {{priceType}}", "stop_loss_beyond_liquidation_error": "Giá cắt lỗ phải {{direction}} giá thanh lý", "stop_loss_order_view_warning": "Giá cắt lỗ {{direction}} giá thanh lý", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "Lệnh chốt lời phải là giá {{priceType}} {{direction}}. Hãy cập nhật hoặc xóa để đặt lệnh.", + "stop_loss_wrong_side_warning": "Lệnh cắt lỗ phải là giá {{priceType}} {{direction}}. Hãy cập nhật hoặc xóa để đặt lệnh.", "above": "cao hơn", "below": "thấp hơn", "done": "Hoàn tất", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "Ước tính {{time}} giây", "placing_prediction": "Đặt dự đoán", "prediction_placed": "Dự đoán đã đặt", + "prediction_failed": "Failed to place prediction", "order_failed": "Đặt lệnh không thành công", "payments_made_in_usdc": "Tất cả các khoản thanh toán được thực hiện bằng USDC", "prediction_insufficient_funds": "Không đủ tiền. Bạn có thể sử dụng tối đa {{amount}}.", @@ -2322,6 +2331,7 @@ "order_failed_title": "Đặt lệnh không thành công", "order_failed_body": "Không có đủ thanh khoản ở mức giá này. Bạn có muốn thử lại không?", "try_again": "Thử lại", + "view": "Xem", "yes_buy": "Có, mua", "yes_sell": "Có, bán" }, @@ -2376,7 +2386,8 @@ "unknown_error": "Đã xảy ra lỗi không xác định", "order_not_fully_filled": "Không thể thực hiện lệnh của bạn", "buy_order_not_fully_filled": "Hiện không có đủ cổ phần theo giá thị trường để đặt lệnh vào lúc này.", - "sell_order_not_fully_filled": "Không có đủ nhu cầu theo giá thị trường để rút tiền ngay bây giờ." + "sell_order_not_fully_filled": "Không có đủ nhu cầu theo giá thị trường để rút tiền ngay bây giờ.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Chọn người chiến thắng", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "Dự đoán đã nạp tiền", "tx_review_predict_claim": "Tiền thắng đã nhận", "tx_review_predict_withdraw": "Rút tiền dự đoán", + "tx_review_perps_withdraw": "Rút tiền hợp đồng vĩnh cửu", "tx_review_musd_conversion": "Chuyển đổi mUSD", "claim": "Nhận", "sent_ether": "Đã gửi ETH", @@ -5992,6 +6004,7 @@ "percentage_bonus": "Thưởng {{percentage}}%", "claimable_bonus": "Thưởng có thể nhận", "claim_bonus": "Nhận thưởng", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "Tiền thưởng sẽ được trả trên {{networkName}}.", "percentage_bonus_on_linea": "Thưởng {{percentage}}% trên Linea", "claim": "Nhận", @@ -6151,54 +6164,54 @@ "your_stablecoins": "Đồng ổn định của bạn" }, "money": { - "title": "Money", - "apy_label": "{{percentage}}% APY", + "title": "Tài chính", + "apy_label": "APY {{percentage}}%", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "Thêm", + "transfer": "Chuyển", + "card": "Thẻ" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "Vị thế của bạn", + "current_rate": "Tỷ giá hiện tại", + "lifetime_earnings": "Thu nhập trọn đời", + "available_balance": "Số dư khả dụng" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "Cách hoạt động", + "description": "Nắm giữ mUSD trong Tài khoản Tài chính và tự động sinh lời. Đây là đồng ổn định được bảo chứng theo USD, luôn có thanh khoản và sẵn sàng để chi tiêu, giao dịch hoặc gửi bất cứ lúc nào.", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "Thêm" }, "potential_earnings": { - "title": "Potential earnings", - "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "title": "Thu nhập tiềm năng", + "amount": "+$26.800", + "description": "Xem tiền của bạn có thể tăng trưởng theo thời gian như thế nào bằng cách chuyển đổi tiền mã hóa sang mUSD.", + "convert": "Chuyển đổi", + "no_fee": "Không tốn phí", + "see_earnings": "Xem thu nhập tiềm năng" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "Thẻ MetaMask", + "subtitle": "Chi tiêu tiền của bạn ở mọi nơi.", + "virtual_card": "Thẻ ảo", + "metal_card": "Thẻ kim loại", + "cashback": "Hoàn tiền {{percentage}}%", + "get_now": "Nhận ngay" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "Tại sao chọn MetaMask Tài chính?", + "benefit_auto_earn": "Tự động sinh lời ", + "benefit_dollar_backed": "Tiền của bạn được nắm giữ dưới dạng mUSD, một đồng ổn định được bảo chứng 1:1 theo USD", + "benefit_liquidity": "Khả năng thanh khoản cao, không bị khóa vốn, cho phép giao dịch hoặc rút tiền bất cứ lúc nào", + "benefit_spend_prefix": "Chi tiêu tại hơn 150 triệu điểm chấp nhận với Thẻ MetaMask và được ", + "benefit_spend_cashback": "hoàn tiền 1-3%", + "benefit_global": "Gửi và nhận tiền toàn cầu mà không cần trung gian", + "learn_more": "Tìm hiểu thêm" }, "footer": { - "add_money": "Add money" + "add_money": "Nạp tiền" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "Cập nhật tài khoản", "approve": "Phê duyệt yêu cầu", "perps_deposit": "Nạp tiền", + "perps_withdraw": "Rút tiền", "predict_deposit": "Nạp tiền Dự đoán", "predict_withdraw": "Rút tiền", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "Trang web này muốn được cấp quyền chi tiêu token của bạn.", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "Giao dịch {{index}}", "transaction": "Bảo vệ", "available_balance": "Số dư khả dụng: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "Số dư Hợp đồng vĩnh cửu khả dụng: ", "edit_amount_done": "Tiếp tục", "deposit_edit_amount_done": "Nạp tiền", "deposit_edit_amount_predict_withdraw": "Rút tiền", @@ -6810,7 +6824,13 @@ "oauth_error_button": "Thử lại", "no_internet_connection_title": "Không thể kết nối", "no_internet_connection_description": "Kết nối internet của bạn không ổn định. Hãy kiểm tra kết nối và thử lại.", - "no_internet_connection_button": "Thử lại" + "no_internet_connection_button": "Thử lại", + "ios_need_update_title": "Cần cập nhật iOS", + "ios_need_update_description": "Đăng nhập Google của MetaMask sẽ sớm yêu cầu ", + "ios_need_update_description_version": "iOS 17.4 trở lên", + "ios_need_update_description_end": ". Bạn vẫn có thể tiếp tục sử dụng Đăng nhập Google trên thiết bị này ở thời điểm hiện tại, nhưng tính năng này sẽ không còn được hỗ trợ trong bản cập nhật sắp tới.", + "ios_need_update_description2": "Bạn vẫn có thể truy cập ví của mình bằng cùng tài khoản Google trên thiết bị được hỗ trợ hoặc tiện ích mở rộng MetaMask. Chúng tôi đặc biệt khuyên bạn nên sao lưu Cụm từ khôi phục bí mật để đảm bảo quyền truy cập không bị gián đoạn.", + "ios_need_update_button": "Tiếp tục" }, "password_hint": { "title": "Gợi ý mật khẩu", @@ -7430,7 +7450,9 @@ "loading": "Đang tải các token khả dụng...", "load_error": "Không thể tải token. Vui lòng thử lại.", "retry": "Thử lại", - "on_linea": "trên Linea" + "on_linea": "trên Linea", + "account_label": "Tài khoản", + "token_label": "Token" }, "cashback_screen": { "title": "Hoàn tiền", @@ -7881,6 +7903,7 @@ "daily_bonus": "Thưởng có thể nhận hằng ngày", "annualized_bonus": "Thưởng theo năm", "disclaimer": "Đây chỉ là ước tính. Phần thưởng có thể thay đổi.", + "disclaimer_brief": "Mức thưởng chỉ là ước tính và có thể thay đổi.", "buy_button": "Mua mUSD", "swap_button": "Hoán đổi sang mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "Bắt đầu {{date}}", "ends_date": "Kết thúc {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "Sắp tới", + "ended_date": "Đã kết thúc {{date}}", + "pill_up_next": "Sắp ra mắt", "pill_active": "Đang diễn ra", "pill_complete": "Hoàn tất", "enter_now": "Tham gia ngay", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "Điều khoản sử dụng bổ sung và Thông báo quyền riêng tư", "opt_in_sheet_description_post_link": "Chúng tôi sẽ theo dõi hoạt động trên chuỗi để tự động tặng thưởng cho bạn.", "geo_restriction_banner_title": "Không khả dụng tại khu vực của bạn", - "geo_restriction_banner_description": "Chiến dịch này không khả dụng tại khu vực của bạn do quy định địa phương." + "geo_restriction_banner_description": "Chiến dịch này không khả dụng tại khu vực của bạn do quy định địa phương.", + "opt_in_success_toast": "Bạn đã sẵn sàng!" }, "campaign_mechanics": { "title": "Cơ chế" @@ -7945,10 +7969,60 @@ "opted_in": "Bạn đã tham gia chiến dịch này", "opt_in_error": "Không thể tham gia. Vui lòng thử lại.", "join_campaign": "Tham gia chiến dịch", + "entries_closed_title": "Đã đóng đăng ký", + "entries_closed_description": "Bạn đã bỏ lỡ thời gian tham gia", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "Đang kiểm tra trạng thái tham gia", "swap": "Hoán đổi", "how_it_works": "Cách hoạt động" }, + "ondo_campaign_leaderboard_position": { + "title": "Vị thế của bạn", + "rank": "Xếp hạng của tôi", + "tier": "Hạng của tôi", + "rate_of_return": "Lợi nhuận", + "total_deposited": "Tổng số tiền đã nạp", + "current_value": "Giá trị hiện tại", + "not_found": "Chưa có trên bảng xếp hạng. Vui lòng quay lại sau.", + "updated_at": "Cập nhật lần cuối: {{time}}", + "error_loading": "Không thể tải vị thế của bạn", + "error_loading_description": "Đã xảy ra lỗi khi tải vị thế của bạn trên bảng xếp hạng. Vui lòng thử lại.", + "retry": "Thử lại" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "Tổng giá trị", + "portfolio_pnl": "Lãi/Lỗ", + "portfolio_pnl_percent": "Lãi/Lỗ (%)", + "summary_cost_basis": "Cơ sở chi phí", + "summary_net_deposit": "Tiền nạp ròng", + "position_current_value": "Giá trị", + "position_unrealized_pnl": "Lãi/Lỗ", + "updated_at": "Cập nhật lần cuối: {{time}}", + "error_loading": "Không thể tải vị thế.", + "error_loading_description": "Đã xảy ra lỗi khi tải các vị thế của bạn. Vui lòng thử lại.", + "retry": "Thử lại", + "loading": "Đang tải vị thế...", + "empty": "Chưa tìm thấy 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" + }, + "ondo_campaign_leaderboard": { + "title": "Bảng xếp hạng", + "your_position": "Vị thế của bạn", + "of_total": "trên tổng số {{total}} người tham gia", + "total_participants": "{{count}} người tham gia", + "updated_at": "Cập nhật lần cuối: {{time}}", + "error_loading": "Không thể tải bảng xếp hạng", + "error_loading_description": "Đã xảy ra lỗi khi tải bảng xếp hạng. Vui lòng thử lại.", + "error_loading_position": "Không thể tải vị thế của bạn", + "retry": "Thử lại", + "no_data": "Không có dữ liệu bảng xếp hạng", + "no_entries_in_tier": "Chưa có người tham gia nào trong hạng này", + "not_yet_computed": "Bảng xếp hạng chưa được tính toán xong. Vui lòng quay lại sau." + }, "campaigns_preview": { "title": "Chiến dịch", "coming_soon": "Sắp ra mắt", @@ -7983,6 +8057,7 @@ "musd_conversion": "Đã chuyển đổi sang mUSD", "musd_claim": "Đã nhận mUSD", "perps_deposit": "Đã nạp tiền tài khoản vĩnh cửu", + "perps_withdraw": "Rút tiền", "predict_claim": "Đã nhận tiền thắng", "predict_deposit": "Tài khoản Dự đoán đã được nạp tiền", "predict_withdraw": "Rút tiền", @@ -8010,6 +8085,7 @@ "musd_convert_send": "Đã gửi {{sourceSymbol}} từ {{sourceChain}}", "musd_claim": "Nhận mUSD", "perps_deposit": "Nạp tiền", + "perps_withdraw": "Rút tiền", "predict_deposit": "Nạp tiền", "swap": "Hoán đổi token", "swap_approval": "Phê duyệt token" @@ -8082,6 +8158,7 @@ "sites": "Trang web", "popular_sites": "Trang web phổ biến", "search_sites": "Tìm kiếm trang web", + "view_all": "Xem tất cả", "enable_basic_functionality": "Bật chức năng cơ bản", "basic_functionality_disabled_title": "Khám phá không khả dụng", "basic_functionality_disabled_description": "Chúng tôi không thể tìm nạp siêu dữ liệu cần thiết khi chức năng cơ bản bị tắt.", @@ -8199,6 +8276,7 @@ "perpetuals": "Hợp đồng vĩnh cửu", "predictions": "Dự đoán", "whats_happening": "Tình hình hiện nay", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Địa chính trị", "macro": "Vĩ mô", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 1afd49f8f4e..4f285ee9b56 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -21,10 +21,10 @@ } }, "access_restricted": { - "title": "Access restricted", - "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", - "description_line2": "If you believe this is an error, contact support to request a review.", - "contact_support": "Contact support" + "title": "访问受限", + "description_line1": "该钱包地址在合规审查中已被标记。因此,部分 MetaMask 服务不可用。", + "description_line2": "若您认为此标记有误,请联系支持团队申请审核。", + "contact_support": "联系支持团队" }, "alert_system": { "alert_modal": { @@ -476,7 +476,10 @@ "biometric_authentication_cancelled": "生物特征认证已取消", "biometric_authentication_cancelled_title": "生物特征设置失败", "biometric_authentication_cancelled_description": "请从设置中重新设置生物特征认证。", - "biometric_authentication_cancelled_button": "确认" + "biometric_authentication_cancelled_button": "确认", + "biometric_changed": "生物识别信息已变更", + "biometric_changed_alert_desc": "您的生物识别信息已变更。请前往设置重新启用生物识别信息。", + "biometric_changed_alert_confirm": "确认" }, "connect_hardware": { "title_select_hardware": "连接硬件钱包", @@ -1008,6 +1011,11 @@ "see_more": "查看更多" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "永续合约不可用", "title": "永续合约", @@ -1477,8 +1485,8 @@ "stop_loss_invalid_price": "须以 {{direction}}{{priceType}} 价格止损", "stop_loss_beyond_liquidation_error": "须以 {{direction}} 清算价格止损", "stop_loss_order_view_warning": "以 {{direction}} 清算价格止损", - "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", - "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "take_profit_wrong_side_warning": "止盈必须设置为 {{direction}} {{priceType}} 价格。请更新或清除后下单。", + "stop_loss_wrong_side_warning": "止损必须设置为 {{direction}} {{priceType}} 价格。请更新或清除后下单。", "above": "高于", "below": "低于", "done": "已完成", @@ -2309,6 +2317,7 @@ "cashing_out_subtitle": "预计 {{time}} 秒", "placing_prediction": "正在提交预测", "prediction_placed": "预测已提交", + "prediction_failed": "Failed to place prediction", "order_failed": "订单下达失败", "payments_made_in_usdc": "所有支付均使用 USDC", "prediction_insufficient_funds": "资金不足。您最多可使用 {{amount}}。", @@ -2322,6 +2331,7 @@ "order_failed_title": "订单下达失败", "order_failed_body": "此价位流动性不足。是否重试?", "try_again": "请重试", + "view": "查看", "yes_buy": "是,买入", "yes_sell": "是,卖出" }, @@ -2376,7 +2386,8 @@ "unknown_error": "发生未知错误", "order_not_fully_filled": "您的订单未能成交", "buy_order_not_fully_filled": "当前市价可用股票数量不足,无法立即执行您的订单。", - "sell_order_not_fully_filled": "当前市价需求不足,无法立即变现。" + "sell_order_not_fully_filled": "当前市价需求不足,无法立即变现。", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "选出赢家", @@ -3993,6 +4004,7 @@ "tx_review_predict_deposit": "带资金押注的预测", "tx_review_predict_claim": "已领取的获胜投注", "tx_review_predict_withdraw": "预测提现", + "tx_review_perps_withdraw": "永续合约提现", "tx_review_musd_conversion": "mUSD 兑换", "claim": "领取", "sent_ether": "已发送 ETH", @@ -5992,6 +6004,7 @@ "percentage_bonus": "{{percentage}}% 奖励", "claimable_bonus": "可领取奖励", "claim_bonus": "领取奖励", + "claim_bonus_with_fiat": "Claim {{amount}}", "claim_bonus_subtitle": "奖励将在 {{networkName}} 上发放。", "percentage_bonus_on_linea": "Linea 上 {{percentage}}% 的奖励", "claim": "领取", @@ -6152,53 +6165,53 @@ }, "money": { "title": "Money", - "apy_label": "{{percentage}}% APY", + "apy_label": "{{percentage}}% 年化收益率", "action": { - "add": "Add", - "transfer": "Transfer", - "card": "Card" + "add": "添加", + "transfer": "转账", + "card": "卡" }, "your_position": { - "title": "Your position", - "current_rate": "Current rate", - "lifetime_earnings": "Lifetime earnings", - "available_balance": "Avail. balance" + "title": "您的仓位", + "current_rate": "当前价格", + "lifetime_earnings": "累计收益", + "available_balance": "可用余额" }, "how_it_works": { - "title": "How it works", - "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "title": "如何运行", + "description": "将 mUSD 存入 Money 账户即可自动赚取收益。它由美元支持,始终具备高流动性,并可随时用于消费、交易或转账。", "musd_name": "MetaMask USD", "musd_symbol": "mUSD", - "add": "Add" + "add": "添加" }, "potential_earnings": { - "title": "Potential earnings", + "title": "潜在收益", "amount": "+$26,800", - "description": "See how your money can grow over time by converting your crypto to mUSD.", - "convert": "Convert", - "no_fee": "No fee", - "see_earnings": "See potential earnings" + "description": "了解将您的加密货币转换为 mUSD 后,资金如何随时间增值。", + "convert": "兑换", + "no_fee": "不收费", + "see_earnings": "查看潜在收益" }, "metamask_card": { - "title": "MetaMask Card", - "subtitle": "Spend your money anywhere.", - "virtual_card": "Virtual card", - "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", - "get_now": "Get now" + "title": "MetaMask 卡", + "subtitle": "可随处使用您的资金。", + "virtual_card": "虚拟卡", + "metal_card": "金属卡", + "cashback": "{{percentage}}% 返现", + "get_now": "立即获取" }, "why_metamask_money": { - "title": "Why MetaMask Money?", - "benefit_auto_earn": "Auto-earn ", - "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", - "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", - "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", - "benefit_global": "Send and receive money globally with no middle man", - "learn_more": "Learn more" + "title": "为何选择 MetaMask Money?", + "benefit_auto_earn": "自动赚取 ", + "benefit_dollar_backed": "您的资金以 mUSD(一种与美元 1:1 锚定的稳定币)形式持有", + "benefit_liquidity": "完全流动性,无锁定期,可随时交易或提取", + "benefit_spend_prefix": "通过 MetaMask 卡可在超过 1.5 亿家商户消费,并赚取 ", + "benefit_spend_cashback": "1-3% 返现", + "benefit_global": "全球范围内无需中间商即可转款和收款", + "learn_more": "了解详情" }, "footer": { - "add_money": "Add money" + "add_money": "存入资金" } }, "stake": { @@ -6497,9 +6510,10 @@ "switch_account_type": "账户更新", "approve": "批准请求", "perps_deposit": "充值", + "perps_withdraw": "提取", "predict_deposit": "存入预测资金", "predict_withdraw": "提取", - "perps_withdraw": "Withdraw" + "money_account_deposit": "Add funds" }, "sub_title": { "permit": "该网站想获得花费您的代币的许可。", @@ -6625,7 +6639,7 @@ "nested_transaction_heading": "交易 {{index}}", "transaction": "交易", "available_balance": "可用余额: ", - "available_perps_balance": "Available Perps balance: ", + "available_perps_balance": "可用永续合约余额: ", "edit_amount_done": "继续", "deposit_edit_amount_done": "充值", "deposit_edit_amount_predict_withdraw": "提取", @@ -6810,7 +6824,13 @@ "oauth_error_button": "请重试", "no_internet_connection_title": "无法连接", "no_internet_connection_description": "您的网络连接不稳定。请检查您的网络连接并重试。", - "no_internet_connection_button": "请重试" + "no_internet_connection_button": "请重试", + "ios_need_update_title": "需要更新 iOS", + "ios_need_update_description": "MetaMask 的 Google 登录功能即将要求 ", + "ios_need_update_description_version": "iOS 17.4 或更高版本", + "ios_need_update_description_end": "。当前您仍可在此设备上继续使用 Google 登录,但该功能将在后续更新中不再受支持。", + "ios_need_update_description2": "您仍可通过支持的设备或 MetaMask 扩展程序使用同一 Google 账户访问钱包。我们强烈建议您备份私钥助记词,以确保访问不中断。", + "ios_need_update_button": "继续" }, "password_hint": { "title": "密码提示", @@ -7430,7 +7450,9 @@ "loading": "正在加载可用代币……", "load_error": "无法加载代币。请重试。", "retry": "请重试", - "on_linea": "在 Linea 上" + "on_linea": "在 Linea 上", + "account_label": "账户", + "token_label": "代币" }, "cashback_screen": { "title": "返现", @@ -7881,6 +7903,7 @@ "daily_bonus": "每日可领取奖励", "annualized_bonus": "年化奖励", "disclaimer": "仅为预估。奖励可能会有变动。", + "disclaimer_brief": "此奖励为预估金额,可能发生变动。", "buy_button": "购买 mUSD", "swap_button": "兑换为 mUSD" }, @@ -7919,8 +7942,8 @@ "campaign": { "starts_date": "开始于 {{date}}", "ends_date": "结束于 {{date}}", - "ended_date": "Ended {{date}}", - "pill_up_next": "即将到来", + "ended_date": "结束时间:{{date}}", + "pill_up_next": "即将推出", "pill_active": "进行中", "pill_complete": "完成", "enter_now": "立即参加", @@ -7932,7 +7955,8 @@ "opt_in_sheet_link_text": "补充使用条款及隐私声明", "opt_in_sheet_description_post_link": "我们将通过追踪链上活动为您自动发放奖励。", "geo_restriction_banner_title": "您所在区域不可用", - "geo_restriction_banner_description": "由于当地法规限制,此活动不适用于您所在地区。" + "geo_restriction_banner_description": "由于当地法规限制,此活动不适用于您所在地区。", + "opt_in_success_toast": "您已通过审核!" }, "campaign_mechanics": { "title": "机制" @@ -7945,10 +7969,60 @@ "opted_in": "您已选择加入此活动", "opt_in_error": "加入失败。请重试。", "join_campaign": "加入活动", + "entries_closed_title": "报名已截止", + "entries_closed_description": "您已错过参与窗口期", + "competition_closed_title": "Competition no longer open", + "competition_closed_description": "Sorry, this competition is no longer open to join. You can follow the leaderboard below and check back for future campaigns.", "checking_opt_in_status": "正在检查加入状态", "swap": "交换", "how_it_works": "如何运行" }, + "ondo_campaign_leaderboard_position": { + "title": "您的排名", + "rank": "我的排名", + "tier": "我的等级", + "rate_of_return": "回报率", + "total_deposited": "累计存入", + "current_value": "当前价值", + "not_found": "暂未上榜。请稍后再查看。", + "updated_at": "最后更新于:{{time}}", + "error_loading": "加载您的排名失败", + "error_loading_description": "加载排行榜排名时出错。请重试。", + "retry": "重试" + }, + "ondo_campaign_portfolio": { + "title": "My Positions", + "total_value": "总价值", + "portfolio_pnl": "损益", + "portfolio_pnl_percent": "损益 (%)", + "summary_cost_basis": "成本基础", + "summary_net_deposit": "净存款", + "position_current_value": "价值", + "position_unrealized_pnl": "损益", + "updated_at": "最后更新于:{{time}}", + "error_loading": "加载仓位失败。", + "error_loading_description": "加载您的仓位时出错。请重试。", + "retry": "重试", + "loading": "正在加载头寸……", + "empty": "暂无持仓记录。", + "empty_description": "通过开立代币化现实世界资产的仓位,开始赚取奖励。", + "empty_cta": "开仓", + "position_units": "{{units}} 份额" + }, + "ondo_campaign_leaderboard": { + "title": "排行榜", + "your_position": "您的排名", + "of_total": "在 {{total}} 位参与者中", + "total_participants": "{{count}} 位参与者", + "updated_at": "最后更新于:{{time}}", + "error_loading": "加载排行榜失败", + "error_loading_description": "加载排行榜时遇到问题。请重试。", + "error_loading_position": "加载您的排名失败", + "retry": "重试", + "no_data": "暂无排行榜数据可用", + "no_entries_in_tier": "本等级目前尚无参与者", + "not_yet_computed": "排行榜尚未计算完毕。请稍后再来查看。" + }, "campaigns_preview": { "title": "活动", "coming_soon": "即将推出", @@ -7983,6 +8057,7 @@ "musd_conversion": "已兑换为 mUSD", "musd_claim": "已领取 mUSD", "perps_deposit": "已注资的永续合约账户", + "perps_withdraw": "提取", "predict_claim": "已领取收益", "predict_deposit": "预测账户已存入资金", "predict_withdraw": "提取", @@ -8010,6 +8085,7 @@ "musd_convert_send": "已从 {{sourceChain}} 发送 {{sourceSymbol}}", "musd_claim": "领取 mUSD", "perps_deposit": "充值", + "perps_withdraw": "提取", "predict_deposit": "充值", "swap": "兑换代币", "swap_approval": "批准代币" @@ -8082,6 +8158,7 @@ "sites": "网站", "popular_sites": "热门网站", "search_sites": "搜索网站", + "view_all": "查看全部", "enable_basic_functionality": "启用基本功能", "basic_functionality_disabled_title": "探索功能不可用", "basic_functionality_disabled_description": "当基础功能被禁用时,我们无法获取所需的元数据。", @@ -8199,6 +8276,7 @@ "perpetuals": "永续合约", "predictions": "预测", "whats_happening": "发生了什么", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "地缘政治", "macro": "宏观", diff --git a/package.json b/package.json index 70d8a8cdf87..2da8e7a564c 100644 --- a/package.json +++ b/package.json @@ -283,7 +283,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", - "@metamask/ramps-controller": "^12.0.1", + "@metamask/ramps-controller": "^12.1.0", "@metamask/react-data-query": "^0.2.0", "@metamask/react-native-acm": "1.2.0", "@metamask/react-native-actionsheet": "2.4.2", diff --git a/yarn.lock b/yarn.lock index 9b0f9fae941..a088a3e4bc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7886,14 +7886,14 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^9.0.0": - version: 9.0.0 - resolution: "@metamask/base-controller@npm:9.0.0" +"@metamask/base-controller@npm:^9.0.0, @metamask/base-controller@npm:^9.0.1": + version: 9.0.1 + resolution: "@metamask/base-controller@npm:9.0.1" dependencies: - "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/messenger": "npm:^1.0.0" + "@metamask/utils": "npm:^11.9.0" immer: "npm:^9.0.6" - checksum: 10/27554d34ec85c4b585b87850c90dfeaaf9c7e6430f2ab2fa80a1ec06ccc17641e118afab7ad765a0b7255ffef37bc9f6ca5065d459228a2dc660bc463293310d + checksum: 10/bc5052c9a38c21a52003e9a79de1f609ff127d939c87eb7b9ebe01cdf05ce2a9ee8e4635dd96f193e9951983e9554d9381af303fbadaae740445ffb2424698e8 languageName: node linkType: hard @@ -9439,14 +9439,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^12.0.1": - version: 12.0.1 - resolution: "@metamask/ramps-controller@npm:12.0.1" +"@metamask/ramps-controller@npm:^12.1.0": + version: 12.1.0 + resolution: "@metamask/ramps-controller@npm:12.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.1" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^0.3.0" - checksum: 10/a7f9428cb824bd0175ee1cc603d77c650fa7a23c7183e2cc0a0f21ee9b6378d80bbd1e496654e40d2edcfc840e60dd4a09d80feacb1746087a67b66761e1e6c7 + "@metamask/messenger": "npm:^1.0.0" + checksum: 10/ae1f3f4cb4dd4493ed4d75220a54ce8e5878b16e3b31dbafdf5ae0256cf3f9b1ef9aad146e61609485c4a682a119fd0fdadc2489862bd6d1c480878f2dc3c409 languageName: node linkType: hard @@ -35652,7 +35652,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^3.1.0" "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^12.0.1" + "@metamask/ramps-controller": "npm:^12.1.0" "@metamask/react-data-query": "npm:^0.2.0" "@metamask/react-native-acm": "npm:1.2.0" "@metamask/react-native-actionsheet": "npm:2.4.2"